@sentinel-atl/hardening 0.3.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 +102 -0
- package/dist/audit-rotation.d.ts +33 -0
- package/dist/audit-rotation.d.ts.map +1 -0
- package/dist/audit-rotation.js +120 -0
- package/dist/audit-rotation.js.map +1 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +117 -0
- package/dist/auth.js.map +1 -0
- package/dist/cors.d.ts +34 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +86 -0
- package/dist/cors.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/nonce-store.d.ts +50 -0
- package/dist/nonce-store.d.ts.map +1 -0
- package/dist/nonce-store.js +88 -0
- package/dist/nonce-store.js.map +1 -0
- package/dist/rate-limit.d.ts +55 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +116 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/security-headers.d.ts +36 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +48 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.d.ts.map +1 -0
- package/dist/tls.js +41 -0
- package/dist/tls.js.map +1 -0
- package/package.json +43 -0
- package/src/__tests__/hardening.test.ts +472 -0
- package/src/audit-rotation.ts +149 -0
- package/src/auth.ts +162 -0
- package/src/cors.ts +118 -0
- package/src/index.ts +62 -0
- package/src/nonce-store.ts +111 -0
- package/src/rate-limit.ts +141 -0
- package/src/security-headers.ts +79 -0
- package/src/tls.ts +66 -0
- package/tsconfig.json +9 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Authentication middleware.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Bearer token in Authorization header
|
|
6
|
+
* - X-API-Key header
|
|
7
|
+
* - ?apiKey query parameter
|
|
8
|
+
*
|
|
9
|
+
* Keys are validated using constant-time comparison to prevent timing attacks.
|
|
10
|
+
* Multiple keys can be configured with different scopes (read, write, admin).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
14
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export type AuthScope = 'read' | 'write' | 'admin';
|
|
19
|
+
|
|
20
|
+
export interface ApiKey {
|
|
21
|
+
/** The key value (plaintext — in production, store hashed) */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Human-readable label */
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Scopes this key grants */
|
|
26
|
+
scopes: AuthScope[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthConfig {
|
|
30
|
+
/** Whether auth is enabled (default: false — opt-in) */
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
/** Registered API keys */
|
|
33
|
+
keys: ApiKey[];
|
|
34
|
+
/** Paths that don't require auth (e.g., /health, badge endpoints) */
|
|
35
|
+
publicPaths?: string[];
|
|
36
|
+
/** Custom realm for WWW-Authenticate header */
|
|
37
|
+
realm?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthResult {
|
|
41
|
+
authenticated: boolean;
|
|
42
|
+
key?: ApiKey;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function constantTimeCompare(a: string, b: string): boolean {
|
|
49
|
+
if (a.length !== b.length) return false;
|
|
50
|
+
const bufA = Buffer.from(a, 'utf-8');
|
|
51
|
+
const bufB = Buffer.from(b, 'utf-8');
|
|
52
|
+
return timingSafeEqual(bufA, bufB);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractApiKey(req: IncomingMessage): string | undefined {
|
|
56
|
+
// 1. Authorization: Bearer <key>
|
|
57
|
+
const authHeader = req.headers['authorization'];
|
|
58
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
59
|
+
return authHeader.slice(7);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. X-API-Key header
|
|
63
|
+
const xApiKey = req.headers['x-api-key'];
|
|
64
|
+
if (typeof xApiKey === 'string' && xApiKey) {
|
|
65
|
+
return xApiKey;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. Query parameter
|
|
69
|
+
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
70
|
+
const queryKey = url.searchParams.get('apiKey');
|
|
71
|
+
if (queryKey) {
|
|
72
|
+
return queryKey;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Auth Check ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Authenticate an incoming request.
|
|
82
|
+
*/
|
|
83
|
+
export function authenticate(req: IncomingMessage, config: AuthConfig): AuthResult {
|
|
84
|
+
if (!config.enabled) {
|
|
85
|
+
return { authenticated: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check public paths
|
|
89
|
+
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
90
|
+
const path = url.pathname;
|
|
91
|
+
if (config.publicPaths?.some(p => path === p || path.startsWith(p + '/'))) {
|
|
92
|
+
return { authenticated: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const providedKey = extractApiKey(req);
|
|
96
|
+
if (!providedKey) {
|
|
97
|
+
return { authenticated: false, error: 'Missing API key' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find matching key (constant-time comparison)
|
|
101
|
+
for (const registeredKey of config.keys) {
|
|
102
|
+
if (constantTimeCompare(providedKey, registeredKey.key)) {
|
|
103
|
+
return { authenticated: true, key: registeredKey };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { authenticated: false, error: 'Invalid API key' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if request has the required scope.
|
|
112
|
+
*/
|
|
113
|
+
export function hasScope(result: AuthResult, required: AuthScope): boolean {
|
|
114
|
+
if (!result.authenticated) return false;
|
|
115
|
+
if (!result.key) return true; // Auth disabled or public path
|
|
116
|
+
if (result.key.scopes.includes('admin')) return true;
|
|
117
|
+
return result.key.scopes.includes(required);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Send a 401 Unauthorized response.
|
|
122
|
+
*/
|
|
123
|
+
export function sendUnauthorized(res: ServerResponse, config: AuthConfig, error?: string): void {
|
|
124
|
+
const realm = config.realm ?? 'Sentinel';
|
|
125
|
+
res.writeHead(401, {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
'WWW-Authenticate': `Bearer realm="${realm}"`,
|
|
128
|
+
});
|
|
129
|
+
res.end(JSON.stringify({ error: error ?? 'Unauthorized' }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Send a 403 Forbidden response.
|
|
134
|
+
*/
|
|
135
|
+
export function sendForbidden(res: ServerResponse, error?: string): void {
|
|
136
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
137
|
+
res.end(JSON.stringify({ error: error ?? 'Forbidden: insufficient scope' }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a default auth config from environment variables.
|
|
142
|
+
*
|
|
143
|
+
* SENTINEL_API_KEYS=key1:read,write;key2:admin
|
|
144
|
+
*/
|
|
145
|
+
export function authConfigFromEnv(): AuthConfig {
|
|
146
|
+
const envKeys = process.env['SENTINEL_API_KEYS'];
|
|
147
|
+
if (!envKeys) {
|
|
148
|
+
return { enabled: false, keys: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keys: ApiKey[] = envKeys.split(';').filter(Boolean).map((entry, i) => {
|
|
152
|
+
const [key, scopeStr] = entry.split(':');
|
|
153
|
+
const scopes = (scopeStr ?? 'read').split(',').map(s => s.trim()) as AuthScope[];
|
|
154
|
+
return { key: key.trim(), label: `key-${i}`, scopes };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
enabled: keys.length > 0,
|
|
159
|
+
keys,
|
|
160
|
+
publicPaths: ['/health'],
|
|
161
|
+
};
|
|
162
|
+
}
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS middleware — configurable origin allowlist.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the `Access-Control-Allow-Origin: *` wildcard with
|
|
5
|
+
* explicit origin checking and proper Vary headers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface CorsConfig {
|
|
13
|
+
/** Allowed origins. Use ['*'] to allow all (not recommended for production). */
|
|
14
|
+
allowedOrigins: string[];
|
|
15
|
+
/** Allowed HTTP methods */
|
|
16
|
+
allowedMethods?: string[];
|
|
17
|
+
/** Allowed request headers */
|
|
18
|
+
allowedHeaders?: string[];
|
|
19
|
+
/** Headers to expose to the browser */
|
|
20
|
+
exposedHeaders?: string[];
|
|
21
|
+
/** Whether to allow credentials (cookies, auth headers) */
|
|
22
|
+
allowCredentials?: boolean;
|
|
23
|
+
/** Max age for preflight cache (seconds) */
|
|
24
|
+
maxAge?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Default Config ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function defaultCorsConfig(): CorsConfig {
|
|
30
|
+
return {
|
|
31
|
+
allowedOrigins: ['*'],
|
|
32
|
+
allowedMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
33
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Caller-Id', 'X-Server-Name'],
|
|
34
|
+
allowCredentials: false,
|
|
35
|
+
maxAge: 86400, // 24 hours
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── CORS Application ───────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Apply CORS headers to a response.
|
|
43
|
+
* Returns true if this was a preflight request (caller should end the response).
|
|
44
|
+
*/
|
|
45
|
+
export function applyCors(
|
|
46
|
+
req: IncomingMessage,
|
|
47
|
+
res: ServerResponse,
|
|
48
|
+
config: CorsConfig
|
|
49
|
+
): boolean {
|
|
50
|
+
const origin = req.headers['origin'];
|
|
51
|
+
|
|
52
|
+
// Determine if origin is allowed
|
|
53
|
+
let allowedOrigin: string;
|
|
54
|
+
if (config.allowedOrigins.includes('*')) {
|
|
55
|
+
// Wildcard — but if credentials are enabled, must echo specific origin
|
|
56
|
+
allowedOrigin = config.allowCredentials && origin ? origin : '*';
|
|
57
|
+
} else if (origin && config.allowedOrigins.includes(origin)) {
|
|
58
|
+
allowedOrigin = origin;
|
|
59
|
+
} else if (!origin) {
|
|
60
|
+
// Same-origin or non-browser request — no CORS header needed
|
|
61
|
+
allowedOrigin = '';
|
|
62
|
+
} else {
|
|
63
|
+
// Origin not allowed — don't set any CORS headers
|
|
64
|
+
allowedOrigin = '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (allowedOrigin) {
|
|
68
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
69
|
+
|
|
70
|
+
// Vary: Origin — critical for caching when origin-specific
|
|
71
|
+
if (allowedOrigin !== '*') {
|
|
72
|
+
res.setHeader('Vary', 'Origin');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (config.allowCredentials) {
|
|
77
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (config.exposedHeaders?.length) {
|
|
81
|
+
res.setHeader('Access-Control-Expose-Headers', config.exposedHeaders.join(', '));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle preflight
|
|
85
|
+
if (req.method === 'OPTIONS') {
|
|
86
|
+
if (config.allowedMethods?.length) {
|
|
87
|
+
res.setHeader('Access-Control-Allow-Methods', config.allowedMethods.join(', '));
|
|
88
|
+
}
|
|
89
|
+
if (config.allowedHeaders?.length) {
|
|
90
|
+
res.setHeader('Access-Control-Allow-Headers', config.allowedHeaders.join(', '));
|
|
91
|
+
}
|
|
92
|
+
if (config.maxAge !== undefined) {
|
|
93
|
+
res.setHeader('Access-Control-Max-Age', String(config.maxAge));
|
|
94
|
+
}
|
|
95
|
+
res.writeHead(204);
|
|
96
|
+
res.end();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a CORS config from environment variable.
|
|
105
|
+
*
|
|
106
|
+
* SENTINEL_CORS_ORIGINS=https://example.com,https://app.example.com
|
|
107
|
+
*/
|
|
108
|
+
export function corsConfigFromEnv(): CorsConfig {
|
|
109
|
+
const envOrigins = process.env['SENTINEL_CORS_ORIGINS'];
|
|
110
|
+
const origins = envOrigins
|
|
111
|
+
? envOrigins.split(',').map(o => o.trim()).filter(Boolean)
|
|
112
|
+
: ['*'];
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...defaultCorsConfig(),
|
|
116
|
+
allowedOrigins: origins,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sentinel-atl/hardening — Production Hardening Middleware
|
|
3
|
+
*
|
|
4
|
+
* Reusable security middleware for all Sentinel HTTP servers:
|
|
5
|
+
* - API key authentication with scoped access
|
|
6
|
+
* - CORS with configurable origin allowlist
|
|
7
|
+
* - TLS/HTTPS support
|
|
8
|
+
* - Rate limit headers (RFC 6585)
|
|
9
|
+
* - Persistent nonce replay protection
|
|
10
|
+
* - Audit log rotation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
authenticate,
|
|
15
|
+
hasScope,
|
|
16
|
+
sendUnauthorized,
|
|
17
|
+
sendForbidden,
|
|
18
|
+
authConfigFromEnv,
|
|
19
|
+
type AuthConfig,
|
|
20
|
+
type AuthScope,
|
|
21
|
+
type ApiKey,
|
|
22
|
+
type AuthResult,
|
|
23
|
+
} from './auth.js';
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
applyCors,
|
|
27
|
+
defaultCorsConfig,
|
|
28
|
+
corsConfigFromEnv,
|
|
29
|
+
type CorsConfig,
|
|
30
|
+
} from './cors.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
createSecureServer,
|
|
34
|
+
tlsConfigFromEnv,
|
|
35
|
+
type TlsConfig,
|
|
36
|
+
} from './tls.js';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
RateLimiter,
|
|
40
|
+
setRateLimitHeaders,
|
|
41
|
+
sendRateLimited,
|
|
42
|
+
parseRateLimit,
|
|
43
|
+
type RateLimitInfo,
|
|
44
|
+
} from './rate-limit.js';
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
NonceStore,
|
|
48
|
+
type NonceStoreConfig,
|
|
49
|
+
} from './nonce-store.js';
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
rotateIfNeeded,
|
|
53
|
+
cleanupRotatedFiles,
|
|
54
|
+
totalLogSize,
|
|
55
|
+
type RotationConfig,
|
|
56
|
+
} from './audit-rotation.js';
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
applySecurityHeaders,
|
|
60
|
+
securityHeadersConfigFromEnv,
|
|
61
|
+
type SecurityHeadersConfig,
|
|
62
|
+
} from './security-headers.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent nonce store — survives server restarts.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the SentinelStore interface to provide a Set-like API for nonce tracking.
|
|
5
|
+
* Nonces auto-expire based on intent expiry to prevent unbounded growth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SentinelStore } from '@sentinel-atl/store';
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface NonceStoreConfig {
|
|
13
|
+
/** Underlying persistent store */
|
|
14
|
+
store: SentinelStore;
|
|
15
|
+
/** Key prefix (default: 'nonce:') */
|
|
16
|
+
prefix?: string;
|
|
17
|
+
/** Default TTL in seconds (default: 300 = 5 minutes) */
|
|
18
|
+
defaultTtl?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Persistent Nonce Set ────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A Set<string>-compatible nonce tracker backed by persistent storage.
|
|
25
|
+
*
|
|
26
|
+
* Drop-in replacement for the in-memory Set<string> used in validateIntent().
|
|
27
|
+
* Nonces are stored with a TTL so they auto-expire and don't grow forever.
|
|
28
|
+
*/
|
|
29
|
+
export class NonceStore {
|
|
30
|
+
private store: SentinelStore;
|
|
31
|
+
private prefix: string;
|
|
32
|
+
private defaultTtl: number;
|
|
33
|
+
|
|
34
|
+
constructor(config: NonceStoreConfig) {
|
|
35
|
+
this.store = config.store;
|
|
36
|
+
this.prefix = config.prefix ?? 'nonce:';
|
|
37
|
+
this.defaultTtl = config.defaultTtl ?? 300;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Check if a nonce has been seen. */
|
|
41
|
+
async has(nonce: string): Promise<boolean> {
|
|
42
|
+
return this.store.has(this.prefix + nonce);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Mark a nonce as seen with optional TTL in seconds. */
|
|
46
|
+
async add(nonce: string, ttlSeconds?: number): Promise<void> {
|
|
47
|
+
await this.store.set(this.prefix + nonce, '1', ttlSeconds ?? this.defaultTtl);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a Set<string>-compatible adapter for use with validateIntent().
|
|
52
|
+
*
|
|
53
|
+
* Returns an object that looks like Set<string> with .has() and .add()
|
|
54
|
+
* but uses async persistent storage under the hood.
|
|
55
|
+
*
|
|
56
|
+
* IMPORTANT: The returned adapter makes .has() and .add() synchronous-looking
|
|
57
|
+
* by maintaining a local cache that's flushed asynchronously. For strict
|
|
58
|
+
* distributed replay protection, use the async methods directly.
|
|
59
|
+
*/
|
|
60
|
+
toSyncAdapter(): Set<string> & { flush(): Promise<void> } {
|
|
61
|
+
const self = this;
|
|
62
|
+
const pendingAdds: Array<{ nonce: string; ttl?: number }> = [];
|
|
63
|
+
const localCache = new Set<string>();
|
|
64
|
+
|
|
65
|
+
type AdapterType = Set<string> & { flush(): Promise<void> };
|
|
66
|
+
|
|
67
|
+
const adapter: AdapterType = {
|
|
68
|
+
has(nonce: string): boolean {
|
|
69
|
+
return localCache.has(nonce);
|
|
70
|
+
},
|
|
71
|
+
add(nonce: string): AdapterType {
|
|
72
|
+
localCache.add(nonce);
|
|
73
|
+
pendingAdds.push({ nonce });
|
|
74
|
+
return adapter;
|
|
75
|
+
},
|
|
76
|
+
get size() {
|
|
77
|
+
return localCache.size;
|
|
78
|
+
},
|
|
79
|
+
async flush(): Promise<void> {
|
|
80
|
+
for (const { nonce } of pendingAdds) {
|
|
81
|
+
await self.add(nonce);
|
|
82
|
+
}
|
|
83
|
+
pendingAdds.length = 0;
|
|
84
|
+
},
|
|
85
|
+
// Satisfy Set interface minimally
|
|
86
|
+
delete: (nonce: string) => localCache.delete(nonce),
|
|
87
|
+
clear: () => localCache.clear(),
|
|
88
|
+
forEach: (cb: (v: string, v2: string, s: Set<string>) => void) => localCache.forEach(cb),
|
|
89
|
+
entries: () => localCache.entries(),
|
|
90
|
+
keys: () => localCache.keys(),
|
|
91
|
+
values: () => localCache.values(),
|
|
92
|
+
[Symbol.iterator]: () => localCache[Symbol.iterator](),
|
|
93
|
+
[Symbol.toStringTag]: 'NonceStore',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return adapter as Set<string> & { flush(): Promise<void> };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pre-load nonces from the store into a local Set for synchronous checking.
|
|
101
|
+
* Useful at startup to restore state from a previous session.
|
|
102
|
+
*/
|
|
103
|
+
async preload(): Promise<Set<string>> {
|
|
104
|
+
const keys = await this.store.keys(this.prefix);
|
|
105
|
+
const set = new Set<string>();
|
|
106
|
+
for (const key of keys) {
|
|
107
|
+
set.add(key.slice(this.prefix.length));
|
|
108
|
+
}
|
|
109
|
+
return set;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit response headers per RFC 6585 / draft-ietf-httpapi-ratelimit-headers.
|
|
3
|
+
*
|
|
4
|
+
* Adds standard headers so clients know their quota status:
|
|
5
|
+
* RateLimit-Limit: total requests allowed per window
|
|
6
|
+
* RateLimit-Remaining: requests remaining in current window
|
|
7
|
+
* RateLimit-Reset: seconds until window resets
|
|
8
|
+
* Retry-After: seconds to wait (only on 429)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ServerResponse } from 'node:http';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface RateLimitInfo {
|
|
16
|
+
/** Maximum requests per window */
|
|
17
|
+
limit: number;
|
|
18
|
+
/** Remaining requests in current window */
|
|
19
|
+
remaining: number;
|
|
20
|
+
/** When the window resets (Unix timestamp in seconds) */
|
|
21
|
+
resetAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Header Application ──────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Set rate limit headers on a response.
|
|
28
|
+
*/
|
|
29
|
+
export function setRateLimitHeaders(res: ServerResponse, info: RateLimitInfo): void {
|
|
30
|
+
const retryAfter = Math.max(0, Math.ceil(info.resetAt - Date.now() / 1000));
|
|
31
|
+
|
|
32
|
+
res.setHeader('RateLimit-Limit', String(info.limit));
|
|
33
|
+
res.setHeader('RateLimit-Remaining', String(Math.max(0, info.remaining)));
|
|
34
|
+
res.setHeader('RateLimit-Reset', String(retryAfter));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send a 429 Too Many Requests response with Retry-After header.
|
|
39
|
+
*/
|
|
40
|
+
export function sendRateLimited(res: ServerResponse, info: RateLimitInfo): void {
|
|
41
|
+
const retryAfter = Math.max(1, Math.ceil(info.resetAt - Date.now() / 1000));
|
|
42
|
+
|
|
43
|
+
res.writeHead(429, {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'Retry-After': String(retryAfter),
|
|
46
|
+
'RateLimit-Limit': String(info.limit),
|
|
47
|
+
'RateLimit-Remaining': '0',
|
|
48
|
+
'RateLimit-Reset': String(retryAfter),
|
|
49
|
+
});
|
|
50
|
+
res.end(JSON.stringify({
|
|
51
|
+
error: 'Too Many Requests',
|
|
52
|
+
retryAfter,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Enhanced Rate Limiter ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Production-grade rate limiter with header support.
|
|
60
|
+
*/
|
|
61
|
+
export class RateLimiter {
|
|
62
|
+
private windows = new Map<string, { count: number; resetAt: number }>();
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private maxRequests: number,
|
|
66
|
+
private windowMs: number
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a request is allowed and return rate limit info.
|
|
71
|
+
*/
|
|
72
|
+
check(key: string): { allowed: boolean; info: RateLimitInfo } {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const entry = this.windows.get(key);
|
|
75
|
+
|
|
76
|
+
if (!entry || now >= entry.resetAt) {
|
|
77
|
+
const resetAt = now + this.windowMs;
|
|
78
|
+
this.windows.set(key, { count: 1, resetAt });
|
|
79
|
+
return {
|
|
80
|
+
allowed: true,
|
|
81
|
+
info: {
|
|
82
|
+
limit: this.maxRequests,
|
|
83
|
+
remaining: this.maxRequests - 1,
|
|
84
|
+
resetAt: Math.ceil(resetAt / 1000),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (entry.count >= this.maxRequests) {
|
|
90
|
+
return {
|
|
91
|
+
allowed: false,
|
|
92
|
+
info: {
|
|
93
|
+
limit: this.maxRequests,
|
|
94
|
+
remaining: 0,
|
|
95
|
+
resetAt: Math.ceil(entry.resetAt / 1000),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
entry.count++;
|
|
101
|
+
return {
|
|
102
|
+
allowed: true,
|
|
103
|
+
info: {
|
|
104
|
+
limit: this.maxRequests,
|
|
105
|
+
remaining: this.maxRequests - entry.count,
|
|
106
|
+
resetAt: Math.ceil(entry.resetAt / 1000),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Clean up expired windows to prevent memory leaks.
|
|
113
|
+
* Call periodically (e.g., every 5 minutes).
|
|
114
|
+
*/
|
|
115
|
+
cleanup(): number {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
let removed = 0;
|
|
118
|
+
for (const [key, entry] of this.windows) {
|
|
119
|
+
if (now >= entry.resetAt) {
|
|
120
|
+
this.windows.delete(key);
|
|
121
|
+
removed++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return removed;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse a rate limit spec like "100/min" into limiter parameters.
|
|
130
|
+
*/
|
|
131
|
+
export function parseRateLimit(spec: string): { max: number; windowMs: number } {
|
|
132
|
+
const match = spec.match(/^(\d+)\/(min|hour|day)$/);
|
|
133
|
+
if (!match) return { max: 100, windowMs: 60_000 };
|
|
134
|
+
|
|
135
|
+
const max = parseInt(match[1]);
|
|
136
|
+
const windowMs = match[2] === 'min' ? 60_000
|
|
137
|
+
: match[2] === 'hour' ? 3_600_000
|
|
138
|
+
: 86_400_000;
|
|
139
|
+
|
|
140
|
+
return { max, windowMs };
|
|
141
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Response Headers — standard HTTP security headers for all Sentinel servers.
|
|
3
|
+
*
|
|
4
|
+
* Sets headers recommended by OWASP:
|
|
5
|
+
* X-Content-Type-Options: nosniff
|
|
6
|
+
* X-Frame-Options: DENY
|
|
7
|
+
* X-XSS-Protection: 0 (modern browsers use CSP instead)
|
|
8
|
+
* Content-Security-Policy: default-src 'none'
|
|
9
|
+
* Strict-Transport-Security: max-age=31536000; includeSubDomains (when TLS)
|
|
10
|
+
* Referrer-Policy: strict-origin-when-cross-origin
|
|
11
|
+
* Permissions-Policy: camera=(), microphone=(), geolocation=()
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ServerResponse } from 'node:http';
|
|
15
|
+
|
|
16
|
+
export interface SecurityHeadersConfig {
|
|
17
|
+
/** Whether to add HSTS header (only makes sense over TLS) */
|
|
18
|
+
hsts?: boolean;
|
|
19
|
+
/** HSTS max-age in seconds (default: 31536000 = 1 year) */
|
|
20
|
+
hstsMaxAge?: number;
|
|
21
|
+
/** Custom Content-Security-Policy (default: "default-src 'none'") */
|
|
22
|
+
contentSecurityPolicy?: string;
|
|
23
|
+
/** Custom Permissions-Policy */
|
|
24
|
+
permissionsPolicy?: string;
|
|
25
|
+
/** Whether to add X-Frame-Options (default: true) */
|
|
26
|
+
frameOptions?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply standard security headers to a response.
|
|
31
|
+
*/
|
|
32
|
+
export function applySecurityHeaders(
|
|
33
|
+
res: ServerResponse,
|
|
34
|
+
config?: SecurityHeadersConfig
|
|
35
|
+
): void {
|
|
36
|
+
// Prevent MIME type sniffing
|
|
37
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
38
|
+
|
|
39
|
+
// Disable legacy XSS filter (CSP is the modern approach)
|
|
40
|
+
res.setHeader('X-XSS-Protection', '0');
|
|
41
|
+
|
|
42
|
+
// Content Security Policy
|
|
43
|
+
res.setHeader(
|
|
44
|
+
'Content-Security-Policy',
|
|
45
|
+
config?.contentSecurityPolicy ?? "default-src 'none'"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Clickjacking protection
|
|
49
|
+
if (config?.frameOptions !== false) {
|
|
50
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Referrer policy
|
|
54
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
55
|
+
|
|
56
|
+
// Permissions policy
|
|
57
|
+
res.setHeader(
|
|
58
|
+
'Permissions-Policy',
|
|
59
|
+
config?.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=()'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// HSTS (only over TLS)
|
|
63
|
+
if (config?.hsts) {
|
|
64
|
+
const maxAge = config.hstsMaxAge ?? 31_536_000;
|
|
65
|
+
res.setHeader('Strict-Transport-Security', `max-age=${maxAge}; includeSubDomains`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a security headers config from environment variables.
|
|
71
|
+
*
|
|
72
|
+
* SENTINEL_HSTS=true (enabled when TLS is enabled)
|
|
73
|
+
*/
|
|
74
|
+
export function securityHeadersConfigFromEnv(): SecurityHeadersConfig {
|
|
75
|
+
return {
|
|
76
|
+
hsts: process.env['SENTINEL_HSTS'] === 'true' ||
|
|
77
|
+
!!process.env['SENTINEL_TLS_CERT'],
|
|
78
|
+
};
|
|
79
|
+
}
|