diff --git a/README.md b/README.md index 337ee4f..ff59710 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,24 @@ Node.js is pinned to **20.x LTS** in CI, which satisfies the `engines >= 18.18` ## Security / dependency update policy +### Runtime response headers + +The API installs Helmet on every route before request IDs, API-key recognition, +rate limiting, or feature routers run. The configured policy preserves the +existing HSTS preload policy, `X-Frame-Options: DENY`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer` behavior +while adding a JSON-API-oriented `Content-Security-Policy`: + +- `default-src 'none'` denies document subresources by default. +- `frame-ancestors 'none'` prevents the API from being embedded in frames. +- explicit `script-src 'none'`, `style-src 'none'`, and related directives keep + inline script/style and `eval` unavailable if a browser renders an API + response. + +`Permissions-Policy` stays explicit at +`geolocation=(), camera=(), microphone=()`. The policy is applied to JSON +responses, CSV/JSON downloads, and Prometheus metrics text exposition. + ### Vulnerability audit Every CI run executes `npm audit --audit-level=high`. A **high** or **critical** advisory blocks the build and must be resolved before merging. diff --git a/package-lock.json b/package-lock.json index b163bcc..fa7bf39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "express": "^5.2.1" + "express": "^5.2.1", + "helmet": "^8.2.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -1644,6 +1645,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 3dbf8c7..3473b80 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "typescript-eslint": "^8.61.1" }, "dependencies": { - "express": "^5.2.1" + "express": "^5.2.1", + "helmet": "^8.2.0" } } diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..89d310d 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -5,6 +5,7 @@ import express, { type Request, type Response, } from "express"; +import helmet from "helmet"; import { apiKeyStore, pauseState, @@ -22,6 +23,7 @@ export function installPreRouteMiddleware(app: Application): void { app.use(createCorsMiddleware()); app.use(express.json({ limit: "100kb" })); app.use(securityHeadersMiddleware); + app.use(permissionsPolicyMiddleware); app.use(requestIdMiddleware); } @@ -68,19 +70,46 @@ function createCorsMiddleware() { }; } -/** Adds the minimal hardening headers used by the original app. */ -function securityHeadersMiddleware( +/** + * Helmet owns the standard response hardening header set. The CSP is tuned for + * this JSON API surface: no document subresources are expected, framing is + * denied, and explicit script/style directives avoid inline or eval fallbacks. + */ +const securityHeadersMiddleware = helmet({ + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + connectSrc: ["'none'"], + fontSrc: ["'none'"], + formAction: ["'none'"], + frameAncestors: ["'none'"], + imgSrc: ["'none'"], + manifestSrc: ["'none'"], + mediaSrc: ["'none'"], + objectSrc: ["'none'"], + scriptSrc: ["'none'"], + scriptSrcAttr: ["'none'"], + styleSrc: ["'none'"], + workerSrc: ["'none'"], + }, + }, + referrerPolicy: { policy: "no-referrer" }, + strictTransportSecurity: { + maxAge: 63_072_000, + includeSubDomains: true, + preload: true, + }, + xFrameOptions: { action: "deny" }, +}); + +/** Keeps the API's explicit browser feature restrictions. */ +function permissionsPolicyMiddleware( _req: Request, res: Response, next: NextFunction ): void { - res.setHeader("X-Content-Type-Options", "nosniff"); - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader( - "Strict-Transport-Security", - "max-age=63072000; includeSubDomains; preload" - ); res.setHeader("Permissions-Policy", "geolocation=(), camera=(), microphone=()"); next(); } diff --git a/src/security-headers.test.ts b/src/security-headers.test.ts new file mode 100644 index 0000000..1a932e9 --- /dev/null +++ b/src/security-headers.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import request, { type Response } from "supertest"; +import { app } from "./index.js"; + +function assertSecurityHeaders(res: Response): void { + assert.strictEqual(res.headers["x-content-type-options"], "nosniff"); + assert.strictEqual(res.headers["x-frame-options"], "DENY"); + assert.strictEqual(res.headers["referrer-policy"], "no-referrer"); + assert.strictEqual( + res.headers["strict-transport-security"], + "max-age=63072000; includeSubDomains; preload" + ); + assert.strictEqual( + res.headers["permissions-policy"], + "geolocation=(), camera=(), microphone=()" + ); + + const csp = res.headers["content-security-policy"]; + assert.ok(csp, "Content-Security-Policy header missing"); + assert.match(csp, /default-src 'none'/); + assert.match(csp, /frame-ancestors 'none'/); + assert.match(csp, /script-src 'none'/); + assert.doesNotMatch(csp, /'unsafe-inline'/); + assert.doesNotMatch(csp, /'unsafe-eval'/); +} + +void describe("Helmet security headers", () => { + void it("adds hardened security headers to JSON API responses", async () => { + const res = await request(app).get("/api/v1/stats"); + + assert.strictEqual(res.status, 200); + assert.match(res.headers["content-type"], /application\/json/); + assertSecurityHeaders(res); + }); + + void it("keeps hardened headers on CSV and JSON download responses", async () => { + const csv = await request(app).get("/api/v1/usage/export.csv"); + assert.strictEqual(csv.status, 200); + assert.match(csv.headers["content-type"], /text\/csv/); + assert.strictEqual( + csv.headers["content-disposition"], + "attachment; filename=usage.csv" + ); + assert.match(csv.text, /^agent,serviceId,total\n/); + assertSecurityHeaders(csv); + + const json = await request(app).get("/api/v1/usage/export.json"); + assert.strictEqual(json.status, 200); + assert.match(json.headers["content-type"], /application\/json/); + assert.strictEqual( + json.headers["content-disposition"], + "attachment; filename=usage.json" + ); + assertSecurityHeaders(json); + }); + + void it("does not interfere with Prometheus metrics exposition", async () => { + const res = await request(app).get("/api/v1/metrics"); + + assert.strictEqual(res.status, 200); + assert.match(res.headers["content-type"], /text\/plain/); + assert.match(res.text, /# HELP agentpay_services_total/); + assertSecurityHeaders(res); + }); +});