diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..48d42f2 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-12-23 - Missing Security Headers +**Vulnerability:** The Express application was missing standard security headers (HSTS, CSP, X-Frame-Options, etc.), increasing risk of XSS, clickjacking, and MIME-sniffing. +**Learning:** Even with manual CORS handling, standard security headers are often overlooked in custom server implementations. +**Prevention:** Implemented a reusable `securityHeaders` middleware in `src/security-headers.ts` that enforces these headers on all responses. diff --git a/src/http-transport.test.ts b/src/http-transport.test.ts index 9f4371a..cbfd778 100644 --- a/src/http-transport.test.ts +++ b/src/http-transport.test.ts @@ -1876,4 +1876,30 @@ describeIfListen('HttpTransport', () => { expect(isAllowed).toBe(true); // localhost always allowed }); }); + + describe('Security Headers', () => { + it('should include standard security headers in responses', async () => { + const response = await request(app).get('/health'); + + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['x-frame-options']).toBe('DENY'); + expect(response.headers['content-security-policy']).toContain("default-src 'self'"); + expect(response.headers['referrer-policy']).toBe('no-referrer'); + expect(response.headers['permissions-policy']).toBe('interest-cohort=()'); + }); + + it('should include HSTS header when X-Forwarded-Proto is https', async () => { + const response = await request(app) + .get('/health') + .set('X-Forwarded-Proto', 'https'); + + expect(response.headers['strict-transport-security']).toContain('max-age=31536000'); + }); + + it('should not include HSTS header on plain HTTP', async () => { + const response = await request(app).get('/health'); + + expect(response.headers['strict-transport-security']).toBeUndefined(); + }); + }); }); diff --git a/src/http-transport.ts b/src/http-transport.ts index f3b67f9..b8119cf 100644 --- a/src/http-transport.ts +++ b/src/http-transport.ts @@ -27,6 +27,7 @@ import type { import { isInitializeRequest } from './jsonrpc-validator.js'; import { MetricsCollector } from './metrics.js'; import { ExternalOAuthProvider } from './oauth-provider.js'; +import { securityHeaders } from './security-headers.js'; import type { AuthInterceptor } from './types/profile.js'; import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from './constants.js'; import { escapeHtmlSafe } from './validation-utils.js'; @@ -106,6 +107,9 @@ export class HttpTransport { next(); }); + // Security headers (standard hardening) + this.app.use(securityHeaders); + // DNS rebinding protection when binding to localhost // Deny requests with mismatched Host headers to prevent DNS rebinding attacks // Applies when server host is localhost/127.0.0.1, regardless of auth configuration diff --git a/src/security-headers.ts b/src/security-headers.ts new file mode 100644 index 0000000..9cb81d9 --- /dev/null +++ b/src/security-headers.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * Middleware to add security headers to all responses + * + * Why: + * - X-Content-Type-Options: nosniff - Prevents MIME-sniffing + * - X-Frame-Options: DENY - Prevents clickjacking + * - Content-Security-Policy: default-src 'self' - Reduces XSS risk + * - Referrer-Policy: no-referrer - Protects user privacy + * - Permissions-Policy: interest-cohort=() - Disables FLoC + */ +export function securityHeaders(req: Request, res: Response, next: NextFunction): void { + // Prevent browser from guessing the MIME type + res.setHeader('X-Content-Type-Options', 'nosniff'); + + // Prevent the site from being embedded in a frame (clickjacking protection) + res.setHeader('X-Frame-Options', 'DENY'); + + // Restrict resources to the same origin (XSS mitigation) + // Note: This is a strict policy; might need adjustment if external resources are needed later + res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"); + + // Do not send referrer header + res.setHeader('Referrer-Policy', 'no-referrer'); + + // Disable FLoC (Federated Learning of Cohorts) + res.setHeader('Permissions-Policy', 'interest-cohort=()'); + + // HSTS (Strict-Transport-Security) + // Only set if the request is secure (HTTPS) or signaled as such by a proxy + if (req.secure || req.headers['x-forwarded-proto'] === 'https') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + next(); +}