diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..b38273b --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,8 @@ +# Sentinel Journal + +This journal records critical security learnings and vulnerability patterns specific to this codebase. + +## 2025-12-31 - Missing Security Headers in HTTP Transport +**Vulnerability:** The HTTP transport layer (`src/http-transport.ts`) was missing standard security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Content-Security-Policy`, `Strict-Transport-Security`). +**Learning:** Even when security middleware is mentioned in memory/documentation, it might not be implemented in the code if it was assumed to be provided by an external proxy or if the implementation was incomplete. +**Prevention:** Added standard security headers in the `setupMiddleware` function of `HttpTransport` class. Added `src/http-transport-headers.test.ts` to verify the presence of these headers. diff --git a/package-lock.json b/package-lock.json index 2438add..d22128b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2178,6 +2178,7 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2283,6 +2284,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2634,6 +2636,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -2684,6 +2687,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3648,6 +3652,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3942,6 +3947,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5250,6 +5256,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -5587,6 +5594,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6895,6 +6903,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7068,6 +7077,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7166,6 +7176,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -7485,6 +7496,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -7610,6 +7622,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/http-transport-headers.test.ts b/src/http-transport-headers.test.ts new file mode 100644 index 0000000..183640c --- /dev/null +++ b/src/http-transport-headers.test.ts @@ -0,0 +1,82 @@ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { HttpTransport } from './http-transport.js'; +import { ConsoleLogger } from './logger.js'; +import { describeIfListen } from './testing/listen-support.js'; + +describeIfListen('HttpTransport Security Headers', () => { + let transport: HttpTransport; + let app: Express; + const logger = new ConsoleLogger(); + + beforeEach(async () => { + const config = { + host: '127.0.0.1', + port: 0, + sessionTimeoutMs: 1800000, + heartbeatEnabled: false, + heartbeatIntervalMs: 30000, + metricsEnabled: false, + metricsPath: '/metrics', + }; + + transport = new HttpTransport(config, logger); + app = (transport as any).app; + transport.setMessageHandler(async () => ({ result: 'ok' })); + }); + + afterEach(async () => { + await transport.stop(); + }); + + it('should set X-Content-Type-Options header', async () => { + const response = await request(app).get('/health'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('should set X-Frame-Options header', async () => { + const response = await request(app).get('/health'); + expect(response.headers['x-frame-options']).toBe('DENY'); + }); + + it('should set Content-Security-Policy header', async () => { + const response = await request(app).get('/health'); + expect(response.headers['content-security-policy']).toBe("default-src 'self'"); + }); + + it('should set HSTS header on non-localhost requests', async () => { + // Mocking a non-localhost request via Host header and potentially modifying transport config if needed + // However, supertest requests are local. We might need to mock req.hostname or check logic. + // Let's create a new transport with a non-localhost host config to simulate production-like env + const prodTransport = new HttpTransport({ + host: '0.0.0.0', // Bind to all interfaces, simulates prod + port: 0, + sessionTimeoutMs: 1800000, + heartbeatEnabled: false, + heartbeatIntervalMs: 30000, + metricsEnabled: false, + metricsPath: '/metrics', + }, logger); + const prodApp = (prodTransport as any).app; + + // We need to trick the middleware into thinking it's not localhost. + // Express req.hostname depends on Host header. + const response = await request(prodApp) + .get('/health') + .set('Host', 'example.com'); + + expect(response.headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains'); + + await prodTransport.stop(); + }); + + it('should NOT set HSTS header on localhost requests', async () => { + const response = await request(app) + .get('/health') + .set('Host', 'localhost'); + + expect(response.headers['strict-transport-security']).toBeUndefined(); + }); +}); diff --git a/src/http-transport.ts b/src/http-transport.ts index f3b67f9..15d4dbf 100644 --- a/src/http-transport.ts +++ b/src/http-transport.ts @@ -127,6 +127,21 @@ export class HttpTransport { next(); }); + // Security: Standard security headers + this.app.use((req: Request, res: Response, next: NextFunction) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Content-Security-Policy', "default-src 'self'"); + + // Strict-Transport-Security (HSTS) + // Only set for non-localhost to facilitate local development + if (req.hostname !== 'localhost' && req.hostname !== '127.0.0.1') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + next(); + }); + // JSON body parser this.app.use(express.json());