lean-claudient-daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/api.js +54 -0
- package/dist/bin/daemon.js +14 -0
- package/dist/metrics.js +31 -0
- package/dist/metricsStore.js +84 -0
- package/dist/middleware/rateLimiting.js +193 -0
- package/dist/middleware/securityHeaders.js +239 -0
- package/dist/middleware/validation.js +181 -0
- package/dist/security/envValidation.js +266 -0
- package/dist/service.js +47 -0
- package/dist/validation/schemas.js +157 -0
- package/package.json +49 -0
- package/src/api.ts +58 -0
- package/src/bin/daemon.ts +18 -0
- package/src/metrics.ts +45 -0
- package/src/metricsStore.ts +114 -0
- package/src/middleware/rateLimiting.ts +244 -0
- package/src/middleware/securityHeaders.ts +305 -0
- package/src/middleware/validation.ts +222 -0
- package/src/security/envValidation.ts +321 -0
- package/src/service.ts +62 -0
- package/src/validation/schemas.ts +190 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @lean-claudient/daemon
|
|
2
|
+
|
|
3
|
+
**Background daemon service** — Phase 5B for continuous monitoring and self-healing.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Long-running background service
|
|
8
|
+
- Token consumption monitoring
|
|
9
|
+
- Self-healing automation
|
|
10
|
+
- Persistent state management
|
|
11
|
+
- Service lifecycle management
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @lean-claudient/daemon
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
lean-claudient-daemon start
|
|
23
|
+
lean-claudient-daemon stop
|
|
24
|
+
lean-claudient-daemon status
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run build # Compile TypeScript
|
|
31
|
+
npm run dev # Watch mode
|
|
32
|
+
npm run test # Run tests
|
|
33
|
+
npm run lint # ESLint
|
|
34
|
+
npm run type-check # TypeScript validation
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
export function createApiRouter(store) {
|
|
3
|
+
const router = Router();
|
|
4
|
+
router.get('/status', (req, res) => {
|
|
5
|
+
const uptime = process.uptime();
|
|
6
|
+
const memory = process.memoryUsage().heapUsed / 1024 / 1024;
|
|
7
|
+
res.json({
|
|
8
|
+
running: true,
|
|
9
|
+
uptime: Math.floor(uptime),
|
|
10
|
+
memory: parseFloat(memory.toFixed(2)),
|
|
11
|
+
pid: process.pid,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
router.get('/metrics', async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const metrics = await store.getAggregateMetrics();
|
|
17
|
+
res.json(metrics);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
res.status(500).json({ error: 'Failed to retrieve metrics' });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
router.get('/budget/:sessionId', async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const budget = await store.getBudgetStatus(req.params.sessionId);
|
|
26
|
+
if (!budget) {
|
|
27
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
28
|
+
}
|
|
29
|
+
res.json(budget);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
res.status(500).json({ error: 'Failed to retrieve budget' });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
router.get('/sessions', async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const sessions = await store.getAllSessions();
|
|
38
|
+
res.json(sessions);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
res.status(500).json({ error: 'Failed to retrieve sessions' });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
router.post('/sessions/:id/checkpoint', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
await store.saveCheckpoint(req.params.id, req.body.checkpoint);
|
|
47
|
+
res.json({ saved: true });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
res.status(500).json({ error: 'Failed to save checkpoint' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return router;
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { LeanClaudientDaemon } from '../service.js';
|
|
4
|
+
const program = new Command();
|
|
5
|
+
program
|
|
6
|
+
.name('lean-claudient-daemon')
|
|
7
|
+
.version('0.1.0')
|
|
8
|
+
.option('-p, --port <port>', 'Port to listen on', '9831')
|
|
9
|
+
.option('-l, --log <path>', 'Log file path')
|
|
10
|
+
.action(async (options) => {
|
|
11
|
+
const daemon = new LeanClaudientDaemon(parseInt(options.port), options.log);
|
|
12
|
+
await daemon.start();
|
|
13
|
+
});
|
|
14
|
+
program.parse();
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function aggregateMetrics(records) {
|
|
2
|
+
if (records.length === 0) {
|
|
3
|
+
return {
|
|
4
|
+
totalInputTokens: 0,
|
|
5
|
+
totalOutputTokens: 0,
|
|
6
|
+
totalTokens: 0,
|
|
7
|
+
totalCost: '$0.00',
|
|
8
|
+
totalSavedTokens: 0,
|
|
9
|
+
averageCompressionRatio: 0,
|
|
10
|
+
sessionCount: 0,
|
|
11
|
+
averageSubagentCount: 0,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const totalInputTokens = records.reduce((sum, r) => sum + r.inputTokens, 0);
|
|
15
|
+
const totalOutputTokens = records.reduce((sum, r) => sum + r.outputTokens, 0);
|
|
16
|
+
const totalTokens = totalInputTokens + totalOutputTokens;
|
|
17
|
+
const totalCost = totalInputTokens * 0.000003 + totalOutputTokens * 0.000015;
|
|
18
|
+
const totalSavedTokens = records.reduce((sum, r) => sum + r.savedTokens, 0);
|
|
19
|
+
const avgCompressionRatio = records.reduce((sum, r) => sum + r.compressionRatio, 0) / records.length;
|
|
20
|
+
const avgSubagentCount = records.reduce((sum, r) => sum + r.subagentCount, 0) / records.length;
|
|
21
|
+
return {
|
|
22
|
+
totalInputTokens,
|
|
23
|
+
totalOutputTokens,
|
|
24
|
+
totalTokens,
|
|
25
|
+
totalCost: `$${totalCost.toFixed(2)}`,
|
|
26
|
+
totalSavedTokens,
|
|
27
|
+
averageCompressionRatio: parseFloat(avgCompressionRatio.toFixed(2)),
|
|
28
|
+
sessionCount: records.length,
|
|
29
|
+
averageSubagentCount: Math.round(avgSubagentCount),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import sqlite3 from 'sqlite3';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { aggregateMetrics } from './metrics.js';
|
|
6
|
+
export class MetricsStore {
|
|
7
|
+
constructor(dbPath) {
|
|
8
|
+
this.dbPath = dbPath || path.join(process.env.HOME, '.lean-claudient', 'daemon.db');
|
|
9
|
+
}
|
|
10
|
+
async initialize() {
|
|
11
|
+
const dir = path.dirname(this.dbPath);
|
|
12
|
+
if (!fs.existsSync(dir)) {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
this.db = new sqlite3.Database(this.dbPath);
|
|
16
|
+
const run = promisify(this.db.run.bind(this.db));
|
|
17
|
+
await run(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
sessionId TEXT NOT NULL,
|
|
21
|
+
timestamp INTEGER NOT NULL,
|
|
22
|
+
inputTokens INTEGER,
|
|
23
|
+
outputTokens INTEGER,
|
|
24
|
+
cost REAL,
|
|
25
|
+
savedTokens INTEGER,
|
|
26
|
+
compressionRatio REAL,
|
|
27
|
+
subagentCount INTEGER,
|
|
28
|
+
UNIQUE(sessionId, timestamp)
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
await run(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
33
|
+
sessionId TEXT PRIMARY KEY,
|
|
34
|
+
checkpoint TEXT NOT NULL,
|
|
35
|
+
timestamp INTEGER NOT NULL
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
async recordMetrics(metrics) {
|
|
40
|
+
const run = promisify(this.db.run.bind(this.db));
|
|
41
|
+
await run(`INSERT OR REPLACE INTO metrics (sessionId, timestamp, inputTokens, outputTokens, cost, savedTokens, compressionRatio, subagentCount)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
43
|
+
metrics.sessionId,
|
|
44
|
+
metrics.timestamp,
|
|
45
|
+
metrics.inputTokens,
|
|
46
|
+
metrics.outputTokens,
|
|
47
|
+
metrics.cost,
|
|
48
|
+
metrics.savedTokens,
|
|
49
|
+
metrics.compressionRatio,
|
|
50
|
+
metrics.subagentCount,
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
async getAggregateMetrics() {
|
|
54
|
+
const all = promisify(this.db.all.bind(this.db));
|
|
55
|
+
const records = (await all('SELECT * FROM metrics WHERE timestamp > ? ORDER BY timestamp DESC LIMIT 1000', [Date.now() - 7 * 24 * 60 * 60 * 1000]));
|
|
56
|
+
return aggregateMetrics(records);
|
|
57
|
+
}
|
|
58
|
+
async getBudgetStatus(sessionId) {
|
|
59
|
+
const get = promisify(this.db.get.bind(this.db));
|
|
60
|
+
const record = (await get('SELECT * FROM metrics WHERE sessionId = ? ORDER BY timestamp DESC LIMIT 1', [sessionId]));
|
|
61
|
+
if (!record)
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
sessionId,
|
|
65
|
+
spent: record.cost,
|
|
66
|
+
limit: 500,
|
|
67
|
+
remaining: 500 - record.cost,
|
|
68
|
+
alert: record.cost > 375 ? 'warning' : 'ok',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async getAllSessions() {
|
|
72
|
+
const all = promisify(this.db.all.bind(this.db));
|
|
73
|
+
return (await all('SELECT DISTINCT sessionId, MIN(timestamp) as startTime, COUNT(*) as recordCount FROM metrics GROUP BY sessionId'));
|
|
74
|
+
}
|
|
75
|
+
async saveCheckpoint(sessionId, checkpoint) {
|
|
76
|
+
const run = promisify(this.db.run.bind(this.db));
|
|
77
|
+
await run('INSERT OR REPLACE INTO checkpoints (sessionId, checkpoint, timestamp) VALUES (?, ?, ?)', [sessionId, checkpoint, Date.now()]);
|
|
78
|
+
}
|
|
79
|
+
async close() {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
this.db.close((err) => (err ? reject(err) : resolve()));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting Middleware
|
|
3
|
+
* Prevents DOS attacks by limiting requests per IP and session
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Rate limit store - stores request counts by key
|
|
7
|
+
* Key format: "ip:address" or "session:sessionId"
|
|
8
|
+
*/
|
|
9
|
+
class RateLimitStore {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.store = new Map();
|
|
12
|
+
// Clean up expired entries every minute
|
|
13
|
+
this.cleanupInterval = setInterval(() => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
for (const [key, value] of this.store.entries()) {
|
|
16
|
+
if (value.resetTime < now) {
|
|
17
|
+
this.store.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}, 60000);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Record a request and return remaining quota
|
|
24
|
+
*/
|
|
25
|
+
recordRequest(key, windowMs, limit) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const entry = this.store.get(key);
|
|
28
|
+
if (!entry || entry.resetTime < now) {
|
|
29
|
+
this.store.set(key, { count: 1, resetTime: now + windowMs });
|
|
30
|
+
return limit - 1;
|
|
31
|
+
}
|
|
32
|
+
entry.count++;
|
|
33
|
+
return Math.max(0, limit - entry.count);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Check if limit is exceeded
|
|
37
|
+
*/
|
|
38
|
+
isLimited(key, windowMs, limit) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const entry = this.store.get(key);
|
|
41
|
+
if (!entry || entry.resetTime < now) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return entry.count >= limit;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get remaining time until reset
|
|
48
|
+
*/
|
|
49
|
+
getResetTime(key) {
|
|
50
|
+
const entry = this.store.get(key);
|
|
51
|
+
if (!entry)
|
|
52
|
+
return 0;
|
|
53
|
+
return Math.max(0, entry.resetTime - Date.now());
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Cleanup
|
|
57
|
+
*/
|
|
58
|
+
destroy() {
|
|
59
|
+
clearInterval(this.cleanupInterval);
|
|
60
|
+
this.store.clear();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const globalStore = new RateLimitStore();
|
|
64
|
+
/**
|
|
65
|
+
* Create rate limit middleware
|
|
66
|
+
*/
|
|
67
|
+
export function rateLimit(config) {
|
|
68
|
+
const { windowMs = 15 * 60 * 1000, // 15 minutes default
|
|
69
|
+
max = 100, keyGenerator = (req) => `ip:${req.ip}`, skip, onLimitReached, } = config;
|
|
70
|
+
return (req, res, next) => {
|
|
71
|
+
if (skip && skip(req)) {
|
|
72
|
+
return next();
|
|
73
|
+
}
|
|
74
|
+
const key = keyGenerator(req);
|
|
75
|
+
const remaining = globalStore.recordRequest(key, windowMs, max);
|
|
76
|
+
const isLimited = globalStore.isLimited(key, windowMs, max);
|
|
77
|
+
const resetTime = globalStore.getResetTime(key);
|
|
78
|
+
// Set rate limit headers
|
|
79
|
+
res.setHeader('X-RateLimit-Limit', max);
|
|
80
|
+
res.setHeader('X-RateLimit-Remaining', Math.max(0, remaining));
|
|
81
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil((Date.now() + resetTime) / 1000));
|
|
82
|
+
if (isLimited) {
|
|
83
|
+
res.setHeader('Retry-After', Math.ceil(resetTime / 1000));
|
|
84
|
+
if (onLimitReached) {
|
|
85
|
+
onLimitReached(req, key);
|
|
86
|
+
}
|
|
87
|
+
return res.status(429).json({
|
|
88
|
+
error: 'Too Many Requests',
|
|
89
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
90
|
+
details: {
|
|
91
|
+
retryAfter: Math.ceil(resetTime / 1000),
|
|
92
|
+
limit: max,
|
|
93
|
+
windowMs,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
next();
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Generic rate limiter by key
|
|
102
|
+
*/
|
|
103
|
+
export function createRateLimiter(config) {
|
|
104
|
+
return rateLimit(config);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Rate limiter by IP address
|
|
108
|
+
*/
|
|
109
|
+
export function createIPRateLimiter(maxRequests, windowMinutes = 15) {
|
|
110
|
+
return rateLimit({
|
|
111
|
+
windowMs: windowMinutes * 60 * 1000,
|
|
112
|
+
max: maxRequests,
|
|
113
|
+
keyGenerator: (req) => `ip:${req.ip || 'unknown'}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Rate limiter by session ID
|
|
118
|
+
*/
|
|
119
|
+
export function createSessionRateLimiter(maxRequests, windowMinutes = 15) {
|
|
120
|
+
return rateLimit({
|
|
121
|
+
windowMs: windowMinutes * 60 * 1000,
|
|
122
|
+
max: maxRequests,
|
|
123
|
+
keyGenerator: (req) => {
|
|
124
|
+
const sessionId = req.params.sessionId || req.query.sessionId;
|
|
125
|
+
return `session:${sessionId || 'unknown'}`;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Rate limiter by user ID (from query or body)
|
|
131
|
+
*/
|
|
132
|
+
export function createUserRateLimiter(maxRequests, windowMinutes = 15) {
|
|
133
|
+
return rateLimit({
|
|
134
|
+
windowMs: windowMinutes * 60 * 1000,
|
|
135
|
+
max: maxRequests,
|
|
136
|
+
keyGenerator: (req) => {
|
|
137
|
+
const userId = req.query.userId || req.body?.userId;
|
|
138
|
+
return `user:${userId || 'anonymous'}`;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Tiered rate limiter configuration
|
|
144
|
+
* Different limits for different endpoints
|
|
145
|
+
*/
|
|
146
|
+
export const RATE_LIMITS = {
|
|
147
|
+
// High frequency - status checks
|
|
148
|
+
STATUS: { max: 100, windowMinutes: 1 },
|
|
149
|
+
// Medium frequency - metrics reads
|
|
150
|
+
METRICS: { max: 60, windowMinutes: 1 },
|
|
151
|
+
// Medium frequency - budget checks
|
|
152
|
+
BUDGET: { max: 60, windowMinutes: 1 },
|
|
153
|
+
// Low frequency - session creation
|
|
154
|
+
SESSIONS: { max: 30, windowMinutes: 1 },
|
|
155
|
+
// Very low frequency - checkpoints
|
|
156
|
+
CHECKPOINT: { max: 10, windowMinutes: 1 },
|
|
157
|
+
// Low frequency - admin operations
|
|
158
|
+
ADMIN: { max: 20, windowMinutes: 1 },
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Create logging function for rate limit violations
|
|
162
|
+
*/
|
|
163
|
+
export function createRateLimitLogger(logFile) {
|
|
164
|
+
return (req, key) => {
|
|
165
|
+
const logEntry = {
|
|
166
|
+
timestamp: new Date().toISOString(),
|
|
167
|
+
key,
|
|
168
|
+
method: req.method,
|
|
169
|
+
path: req.path,
|
|
170
|
+
ip: req.ip,
|
|
171
|
+
userAgent: req.get('User-Agent'),
|
|
172
|
+
};
|
|
173
|
+
if (logFile) {
|
|
174
|
+
// Would log to file in production
|
|
175
|
+
console.warn('[RATE_LIMIT]', JSON.stringify(logEntry));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
console.warn('[RATE_LIMIT]', logEntry);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Cleanup rate limiter
|
|
184
|
+
*/
|
|
185
|
+
export function destroyRateLimiter() {
|
|
186
|
+
globalStore.destroy();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Reset rate limiter for testing
|
|
190
|
+
*/
|
|
191
|
+
export function resetRateLimiter() {
|
|
192
|
+
destroyRateLimiter();
|
|
193
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Headers Middleware
|
|
3
|
+
* Adds security-related HTTP headers to all responses
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Default security headers configuration
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
hstsMaxAge: 31536000, // 1 year
|
|
10
|
+
hstsIncludeSubdomains: true,
|
|
11
|
+
hstsPreload: true,
|
|
12
|
+
noSniff: true,
|
|
13
|
+
frameOptions: 'DENY',
|
|
14
|
+
xssProtection: true,
|
|
15
|
+
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
16
|
+
cspDirectives: {
|
|
17
|
+
'default-src': "'none'",
|
|
18
|
+
'script-src': "'self'",
|
|
19
|
+
'style-src': "'self'",
|
|
20
|
+
'img-src': "'self'",
|
|
21
|
+
'font-src': "'self'",
|
|
22
|
+
'connect-src': "'self'",
|
|
23
|
+
'frame-ancestors': "'none'",
|
|
24
|
+
'base-uri': "'self'",
|
|
25
|
+
'form-action': "'self'",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Create security headers middleware
|
|
30
|
+
*/
|
|
31
|
+
export function securityHeaders(config = {}) {
|
|
32
|
+
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
|
33
|
+
return (req, res, next) => {
|
|
34
|
+
// Strict-Transport-Security (HSTS)
|
|
35
|
+
if (finalConfig.hstsMaxAge !== undefined) {
|
|
36
|
+
let hstsValue = `max-age=${finalConfig.hstsMaxAge}`;
|
|
37
|
+
if (finalConfig.hstsIncludeSubdomains) {
|
|
38
|
+
hstsValue += '; includeSubDomains';
|
|
39
|
+
}
|
|
40
|
+
if (finalConfig.hstsPreload) {
|
|
41
|
+
hstsValue += '; preload';
|
|
42
|
+
}
|
|
43
|
+
res.setHeader('Strict-Transport-Security', hstsValue);
|
|
44
|
+
}
|
|
45
|
+
// X-Content-Type-Options
|
|
46
|
+
if (finalConfig.noSniff !== false) {
|
|
47
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
48
|
+
}
|
|
49
|
+
// X-Frame-Options
|
|
50
|
+
if (finalConfig.frameOptions) {
|
|
51
|
+
res.setHeader('X-Frame-Options', finalConfig.frameOptions);
|
|
52
|
+
}
|
|
53
|
+
// X-XSS-Protection
|
|
54
|
+
if (finalConfig.xssProtection !== false) {
|
|
55
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
56
|
+
}
|
|
57
|
+
// Referrer-Policy
|
|
58
|
+
if (finalConfig.referrerPolicy) {
|
|
59
|
+
res.setHeader('Referrer-Policy', finalConfig.referrerPolicy);
|
|
60
|
+
}
|
|
61
|
+
// Content-Security-Policy (CSP)
|
|
62
|
+
if (finalConfig.cspDirectives) {
|
|
63
|
+
const cspValue = Object.entries(finalConfig.cspDirectives)
|
|
64
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
65
|
+
.join('; ');
|
|
66
|
+
res.setHeader('Content-Security-Policy', cspValue);
|
|
67
|
+
}
|
|
68
|
+
// Permissions-Policy
|
|
69
|
+
if (finalConfig.permissionsPolicy) {
|
|
70
|
+
const ppValue = Object.entries(finalConfig.permissionsPolicy)
|
|
71
|
+
.map(([key, value]) => `${key}=(${value})`)
|
|
72
|
+
.join(', ');
|
|
73
|
+
res.setHeader('Permissions-Policy', ppValue);
|
|
74
|
+
}
|
|
75
|
+
// Additional security headers
|
|
76
|
+
res.setHeader('X-Powered-By', 'Lean Claudient');
|
|
77
|
+
res.setHeader('Server', 'Lean Claudient Daemon');
|
|
78
|
+
next();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Middleware for API endpoints (stricter CSP)
|
|
83
|
+
*/
|
|
84
|
+
export function securityHeadersAPI(config = {}) {
|
|
85
|
+
const apiConfig = {
|
|
86
|
+
...DEFAULT_CONFIG,
|
|
87
|
+
cspDirectives: {
|
|
88
|
+
'default-src': "'none'",
|
|
89
|
+
'script-src': "'none'",
|
|
90
|
+
'style-src': "'none'",
|
|
91
|
+
'img-src': "'none'",
|
|
92
|
+
'font-src': "'none'",
|
|
93
|
+
'connect-src': "'none'",
|
|
94
|
+
'frame-ancestors': "'none'",
|
|
95
|
+
'base-uri': "'self'",
|
|
96
|
+
'form-action': "'none'",
|
|
97
|
+
},
|
|
98
|
+
...config,
|
|
99
|
+
};
|
|
100
|
+
return securityHeaders(apiConfig);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Remove potentially dangerous headers
|
|
104
|
+
*/
|
|
105
|
+
export function removeDangerousHeaders(req, res, next) {
|
|
106
|
+
// Remove headers that might leak information
|
|
107
|
+
const dangerous = ['X-Powered-By', 'Server', 'X-AspNet-Version', 'X-Runtime-Version'];
|
|
108
|
+
for (const header of dangerous) {
|
|
109
|
+
res.removeHeader(header);
|
|
110
|
+
}
|
|
111
|
+
// Disable caching for sensitive data
|
|
112
|
+
if (req.path.includes('/api/')) {
|
|
113
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
114
|
+
res.setHeader('Pragma', 'no-cache');
|
|
115
|
+
res.setHeader('Expires', '0');
|
|
116
|
+
}
|
|
117
|
+
next();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Validate request headers
|
|
121
|
+
*/
|
|
122
|
+
export function validateRequestHeaders(req, res, next) {
|
|
123
|
+
const dangerousPatterns = [
|
|
124
|
+
/javascript:/i,
|
|
125
|
+
/on\w+=/i, // onclick, onload, etc
|
|
126
|
+
/eval\(/i,
|
|
127
|
+
];
|
|
128
|
+
// Check headers for suspicious content
|
|
129
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
130
|
+
if (typeof value !== 'string')
|
|
131
|
+
continue;
|
|
132
|
+
for (const pattern of dangerousPatterns) {
|
|
133
|
+
if (pattern.test(value)) {
|
|
134
|
+
return res.status(400).json({
|
|
135
|
+
error: 'Invalid request header',
|
|
136
|
+
code: 'INVALID_HEADER',
|
|
137
|
+
details: {
|
|
138
|
+
header: key,
|
|
139
|
+
message: 'Header contains suspicious content',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
next();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Enforce HTTPS redirect for non-local environments
|
|
149
|
+
*/
|
|
150
|
+
export function enforceHTTPS(req, res, next) {
|
|
151
|
+
// Skip for localhost and internal IPs
|
|
152
|
+
if (req.hostname === 'localhost' ||
|
|
153
|
+
req.hostname === '127.0.0.1' ||
|
|
154
|
+
req.ip === '::1' ||
|
|
155
|
+
req.ip?.startsWith('127.') ||
|
|
156
|
+
req.ip?.startsWith('192.168.') ||
|
|
157
|
+
req.ip?.startsWith('10.')) {
|
|
158
|
+
return next();
|
|
159
|
+
}
|
|
160
|
+
// Check if connection is secure
|
|
161
|
+
const isSecure = req.secure || req.get('X-Forwarded-Proto') === 'https';
|
|
162
|
+
if (!isSecure) {
|
|
163
|
+
return res.redirect(301, `https://${req.get('Host')}${req.url}`);
|
|
164
|
+
}
|
|
165
|
+
next();
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Add Content Security Policy meta tag (for responses with HTML)
|
|
169
|
+
*/
|
|
170
|
+
export function cspMetaTag(config = {}) {
|
|
171
|
+
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
|
172
|
+
return (req, res, next) => {
|
|
173
|
+
// Store CSP config on response for template use
|
|
174
|
+
if (finalConfig.cspDirectives) {
|
|
175
|
+
const cspValue = Object.entries(finalConfig.cspDirectives)
|
|
176
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
177
|
+
.join('; ');
|
|
178
|
+
res.locals = { cspMetaTag: cspValue };
|
|
179
|
+
}
|
|
180
|
+
next();
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Prevent information disclosure
|
|
185
|
+
*/
|
|
186
|
+
export function preventInformationDisclosure(req, res, next) {
|
|
187
|
+
const originalJson = res.json;
|
|
188
|
+
res.json = function (body) {
|
|
189
|
+
// Remove stack traces from error responses in production
|
|
190
|
+
if (process.env.NODE_ENV === 'production') {
|
|
191
|
+
if (body && typeof body === 'object') {
|
|
192
|
+
delete body.stack;
|
|
193
|
+
delete body.stackTrace;
|
|
194
|
+
delete body.internal;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return originalJson.call(this, body);
|
|
198
|
+
};
|
|
199
|
+
next();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Create all security headers middleware stack
|
|
203
|
+
*/
|
|
204
|
+
export function createSecurityStack(config = {}) {
|
|
205
|
+
return [
|
|
206
|
+
validateRequestHeaders,
|
|
207
|
+
removeDangerousHeaders,
|
|
208
|
+
preventInformationDisclosure,
|
|
209
|
+
securityHeaders(config),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Strict security headers for admin endpoints
|
|
214
|
+
*/
|
|
215
|
+
export const strictSecurityHeaders = securityHeaders({
|
|
216
|
+
hstsMaxAge: 31536000,
|
|
217
|
+
frameOptions: 'DENY',
|
|
218
|
+
cspDirectives: {
|
|
219
|
+
'default-src': "'none'",
|
|
220
|
+
'script-src': "'self'",
|
|
221
|
+
'style-src': "'self'",
|
|
222
|
+
'img-src': "'self'",
|
|
223
|
+
'font-src': "'self'",
|
|
224
|
+
'connect-src': "'self'",
|
|
225
|
+
'frame-ancestors': "'none'",
|
|
226
|
+
'base-uri': "'self'",
|
|
227
|
+
'form-action': "'self'",
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* Relaxed security headers for public APIs (JSON only)
|
|
232
|
+
*/
|
|
233
|
+
export const apiSecurityHeaders = securityHeaders({
|
|
234
|
+
cspDirectives: {
|
|
235
|
+
'default-src': "'none'",
|
|
236
|
+
'frame-ancestors': "'none'",
|
|
237
|
+
'base-uri': "'self'",
|
|
238
|
+
},
|
|
239
|
+
});
|