@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.
Files changed (44) hide show
  1. package/README.md +102 -0
  2. package/dist/audit-rotation.d.ts +33 -0
  3. package/dist/audit-rotation.d.ts.map +1 -0
  4. package/dist/audit-rotation.js +120 -0
  5. package/dist/audit-rotation.js.map +1 -0
  6. package/dist/auth.d.ts +59 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +117 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/cors.d.ts +34 -0
  11. package/dist/cors.d.ts.map +1 -0
  12. package/dist/cors.js +86 -0
  13. package/dist/cors.js.map +1 -0
  14. package/dist/index.d.ts +19 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +19 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/nonce-store.d.ts +50 -0
  19. package/dist/nonce-store.d.ts.map +1 -0
  20. package/dist/nonce-store.js +88 -0
  21. package/dist/nonce-store.js.map +1 -0
  22. package/dist/rate-limit.d.ts +55 -0
  23. package/dist/rate-limit.d.ts.map +1 -0
  24. package/dist/rate-limit.js +116 -0
  25. package/dist/rate-limit.js.map +1 -0
  26. package/dist/security-headers.d.ts +36 -0
  27. package/dist/security-headers.d.ts.map +1 -0
  28. package/dist/security-headers.js +48 -0
  29. package/dist/security-headers.js.map +1 -0
  30. package/dist/tls.d.ts +33 -0
  31. package/dist/tls.d.ts.map +1 -0
  32. package/dist/tls.js +41 -0
  33. package/dist/tls.js.map +1 -0
  34. package/package.json +43 -0
  35. package/src/__tests__/hardening.test.ts +472 -0
  36. package/src/audit-rotation.ts +149 -0
  37. package/src/auth.ts +162 -0
  38. package/src/cors.ts +118 -0
  39. package/src/index.ts +62 -0
  40. package/src/nonce-store.ts +111 -0
  41. package/src/rate-limit.ts +141 -0
  42. package/src/security-headers.ts +79 -0
  43. package/src/tls.ts +66 -0
  44. 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
+ }