From 55529ecec42f2b0e507ffd2be26a2cacb6fe4d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 28 Mar 2026 09:58:13 +0100 Subject: [PATCH] fix: CSP user directives now override defaults instead of duplicating (#3552) User-provided CSP directives with the same directive name as a default (e.g. img-src) were appended alongside the default, producing duplicate directives in the header. Now user directives replace defaults that share the same directive name. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/middlewares/csp.ts | 16 +++++++--- packages/fresh/src/middlewares/csp_test.ts | 37 +++++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/fresh/src/middlewares/csp.ts b/packages/fresh/src/middlewares/csp.ts index 6fd91c734f8..181ef6d9f5c 100644 --- a/packages/fresh/src/middlewares/csp.ts +++ b/packages/fresh/src/middlewares/csp.ts @@ -51,12 +51,20 @@ export function csp(options: CSPOptions = {}): Middleware { "upgrade-insecure-requests", ]; - const cspDirectives = [...defaultCsp, ...csp]; + // User-provided directives override defaults with the same name + const userDirectiveNames = new Set( + csp.map((d) => d.split(" ")[0]), + ); + const merged = defaultCsp.filter((d) => + !userDirectiveNames.has(d.split(" ")[0]) + ); + merged.push(...csp); + if (reportTo) { - cspDirectives.push(`report-to csp-endpoint`); - cspDirectives.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it + merged.push(`report-to csp-endpoint`); + merged.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it } - const cspString = cspDirectives.join("; "); + const cspString = merged.join("; "); return async (ctx) => { const res = await ctx.next(); diff --git a/packages/fresh/src/middlewares/csp_test.ts b/packages/fresh/src/middlewares/csp_test.ts index bdaa9738068..6cf34b12d8b 100644 --- a/packages/fresh/src/middlewares/csp_test.ts +++ b/packages/fresh/src/middlewares/csp_test.ts @@ -28,17 +28,46 @@ Deno.test("CSP - GET with override options", async () => { .handler(); const res = await handler(new Request("https://localhost/")); + const header = res.headers.get("Content-Security-Policy")!; expect(res.status).toBe(200); - expect(res.headers.get("Content-Security-Policy")).toContain( - "font-src 'self' 'https://fonts.gstatic.com'; style-src 'self' 'https://fonts.googleapis.com'", + expect(header).toContain( + "font-src 'self' 'https://fonts.gstatic.com'", ); - expect(res.headers.get("Content-Security-Policy")).toContain( - "report-uri /api/csp-reports", + expect(header).toContain( + "style-src 'self' 'https://fonts.googleapis.com'", ); + expect(header).toContain("report-uri /api/csp-reports"); expect(res.headers.get("Reporting-Endpoints")).toBe( 'csp-endpoint="/api/csp-reports"', ); + + // Overrides should replace defaults, not duplicate them + const fontSrcCount = header.split("font-src").length - 1; + expect(fontSrcCount).toBe(1); + const styleSrcCount = header.split("style-src").length - 1; + expect(styleSrcCount).toBe(1); +}); + +Deno.test("CSP - user directives override defaults", async () => { + const handler = new App() + .use(csp({ + csp: [ + "img-src 'self' https://example.com data:", + ], + })) + .get("/", () => new Response("ok")) + .handler(); + + const res = await handler(new Request("https://localhost/")); + const header = res.headers.get("Content-Security-Policy")!; + + // Should contain the user's img-src, not the default + expect(header).toContain("img-src 'self' https://example.com data:"); + + // Should not duplicate img-src + const imgSrcCount = header.split("img-src").length - 1; + expect(imgSrcCount).toBe(1); }); Deno.test("CSP - GET report only", async () => {