diff --git a/packages/fresh/src/middlewares/csp.ts b/packages/fresh/src/middlewares/csp.ts index b7eb8eed76d..0f0b6f558c4 100644 --- a/packages/fresh/src/middlewares/csp.ts +++ b/packages/fresh/src/middlewares/csp.ts @@ -82,19 +82,26 @@ 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 headerName = reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"; if (!useNonce) { // Static CSP — no per-request nonce - const cspString = cspDirectives.join("; "); + const cspString = merged.join("; "); return async (ctx) => { const res = await ctx.next(); res.headers.set(headerName, cspString); @@ -113,7 +120,7 @@ export function csp(options: CSPOptions = {}): Middleware { let directives: string[]; if (nonce) { - directives = cspDirectives.map((d) => { + directives = merged.map((d) => { const spaceIdx = d.indexOf(" "); const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx); if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) { @@ -122,7 +129,7 @@ export function csp(options: CSPOptions = {}): Middleware { return d; }); } else { - directives = cspDirectives; + directives = merged; } res.headers.set(headerName, directives.join("; ")); diff --git a/packages/fresh/src/middlewares/csp_test.tsx b/packages/fresh/src/middlewares/csp_test.tsx index 42603eb9ed2..3fc50f0fd4c 100644 --- a/packages/fresh/src/middlewares/csp_test.tsx +++ b/packages/fresh/src/middlewares/csp_test.tsx @@ -30,17 +30,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 () => {