From 6d8ee0fd37aa2903d21f4618c50c44c81abed2d1 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 29 Sep 2026 16:41:09 +0000 Subject: [PATCH 1/2] fix: enable strict baseline Content-Security-Policy in Helmet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helmet was registered with contentSecurityPolicy: false, disabling CSP entirely and leaving any XSS reaching the browser without a second line of defence. Enable a strict CSP locking every directive to 'none' — the manager is a JSON API and serves no application HTML — and let the one HTML surface, the Swagger UI at /api/docs, emit its own compatible CSP via swagger-ui's staticCSP option so the docs page keeps working. Closes #237 Co-Authored-By: Claude Opus 4.8 --- src/api.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 7a1d5207..723e715c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -74,8 +74,19 @@ export default async (opts: ApiOptions) => { api.register(fastifyCookie); // register security headers + // Enable a strict baseline CSP. This is a JSON API and does not serve + // application HTML, so the policy can lock everything down to 'none'. The + // one HTML surface, the Swagger UI at /api/docs, emits its own compatible + // CSP via `staticCSP: true` below, which overrides this on that route. api.register(helmet, { - contentSecurityPolicy: false // CSP managed per-deployment + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + formAction: ["'none'"], + frameAncestors: ["'none'"] + } + } }); // Dynamic CORS: permissive for WHIP/WHEP routes, restrictive for everything else @@ -117,7 +128,10 @@ export default async (opts: ApiOptions) => { } }); api.register(swaggerUI, { - routePrefix: '/api/docs' + routePrefix: '/api/docs', + // Emit a CSP tailored to Swagger UI's own assets so the docs page keeps + // working under the strict global helmet CSP registered above. + staticCSP: true }); api.register(healthcheck, { title: opts.title }); From 81c38cde8c16ecf2155cf71dab5135cd42951707 Mon Sep 17 00:00:00 2001 From: daily-backlog-pr Date: Tue, 29 Sep 2026 17:51:04 +0000 Subject: [PATCH 2/2] test: add CSP regression tests for strict Helmet policy Locks in both halves of the onRequest/onSend CSP interaction from #237: API routes carry the strict default-src 'none' baseline, while /api/docs keeps Swagger UI's own default-src 'self' policy so the docs page works. Addresses the self-review Needs-Changes feedback on PR #380. --- src/api.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/api.test.ts b/src/api.test.ts index dd1efcc3..19c44db6 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -102,3 +102,45 @@ describe('api', () => { expect(response.body).toBe('Hello, world! I am my awesome service'); }); }); + +describe('security headers (CSP)', () => { + const buildServer = () => + api({ + title: 'my awesome service', + smbServerBaseUrl: 'http://localhost', + endpointIdleTimeout: '60', + publicHost: 'http://localhost', + dbManager: mockDbManager, + productionManager: mockProductionManager, + ingestManager: mockIngestManager, + coreFunctions: new CoreFunctions( + mockProductionManager, + new ConnectionQueue() + ) + }); + + // The global helmet CSP is registered via an onRequest hook; swagger-ui's + // `staticCSP: true` registers an encapsulated onSend hook scoped to + // /api/docs that overrides it there only. These two tests lock in both + // halves of that interaction so a future refactor cannot silently weaken + // the API policy or break the docs page. + it('applies the strict baseline CSP to API routes', async () => { + const server = await buildServer(); + const response = await server.inject({ method: 'GET', url: '/' }); + const csp = response.headers['content-security-policy'] as string; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("base-uri 'none'"); + expect(csp).toContain("form-action 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + it('does not leak the strict policy onto the Swagger docs page', async () => { + const server = await buildServer(); + const response = await server.inject({ method: 'GET', url: '/api/docs/' }); + const csp = response.headers['content-security-policy'] as string; + expect(response.statusCode).toBe(200); + // Swagger UI emits its own policy so the docs page keeps working. + expect(csp).toContain("default-src 'self'"); + expect(csp).not.toContain("default-src 'none'"); + }); +});