From 26912b3ef163ffec07df870015542720b57437fd Mon Sep 17 00:00:00 2001 From: mck09 Date: Mon, 31 Aug 2026 09:47:51 +0200 Subject: [PATCH] fix(csp): warn in development when a response has no nonce --- packages/fresh/src/middlewares/csp.ts | 15 ++++++ packages/fresh/src/middlewares/csp_test.tsx | 56 +++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/fresh/src/middlewares/csp.ts b/packages/fresh/src/middlewares/csp.ts index 0f0b6f558c4..5541248d88e 100644 --- a/packages/fresh/src/middlewares/csp.ts +++ b/packages/fresh/src/middlewares/csp.ts @@ -112,6 +112,18 @@ export function csp(options: CSPOptions = {}): Middleware { }; } + const warnedNoncelessPaths = new Set(); + + function warnMissingNonce(pathname: string) { + if (warnedNoncelessPaths.has(pathname)) return; + warnedNoncelessPaths.add(pathname); + // deno-lint-ignore no-console + console.warn( + `🍋 %c[WARNING] CSP: "${pathname}" responded without a nonce, so 'unsafe-inline' was kept. Only ctx.render() sets a nonce.`, + "color:rgb(251, 184, 0)", + ); + } + // Nonce-based CSP — replace 'unsafe-inline' with nonce per request return async (ctx) => { const res = await ctx.next(); @@ -129,6 +141,9 @@ export function csp(options: CSPOptions = {}): Middleware { return d; }); } else { + if (ctx.config.mode === "development") { + warnMissingNonce(ctx.url.pathname); + } directives = merged; } diff --git a/packages/fresh/src/middlewares/csp_test.tsx b/packages/fresh/src/middlewares/csp_test.tsx index 3fc50f0fd4c..94d6b48f1c3 100644 --- a/packages/fresh/src/middlewares/csp_test.tsx +++ b/packages/fresh/src/middlewares/csp_test.tsx @@ -1,4 +1,6 @@ import { expect } from "@std/expect/expect"; +import { fn } from "@std/expect"; +import { stub } from "@std/testing/mock"; import { App } from "../app.ts"; import { csp } from "./csp.ts"; import { FakeServer } from "../test_utils.ts"; @@ -260,3 +262,57 @@ Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => { // default-src should have nonce, not unsafe-inline expect(cspHeader).toMatch(/default-src 'self' 'nonce-[a-f0-9]+'/); }); + +Deno.test("CSP - warns in development when a response has no nonce", async () => { + // deno-lint-ignore no-explicit-any + using warnSpy = stub(console, "warn", fn(() => {}) as any); + const app = new App({ mode: "development" }) + .use(csp({ useNonce: true })) + .get("/api", () => new Response(JSON.stringify({ ok: true }))); + + const server = new FakeServer(app.handler()); + const res = await server.get("/api"); + await res.body?.cancel(); + + expect(res.headers.get("Content-Security-Policy")).toContain( + "'unsafe-inline'", + ); + expect(warnSpy.fake).toHaveBeenCalledTimes(1); + expect(warnSpy.fake).toHaveBeenLastCalledWith( + `🍋 %c[WARNING] CSP: "/api" responded without a nonce, so 'unsafe-inline' was kept. Only ctx.render() sets a nonce.`, + expect.any(String), + ); +}); + +Deno.test("CSP - warns once per path, not once per request", async () => { + // deno-lint-ignore no-explicit-any + using warnSpy = stub(console, "warn", fn(() => {}) as any); + const app = new App({ mode: "development" }) + .use(csp({ useNonce: true })) + .get("/repeated", () => new Response("ok")); + + const server = new FakeServer(app.handler()); + for (let i = 0; i < 3; i++) { + const res = await server.get("/repeated"); + await res.body?.cancel(); + } + + expect(warnSpy.fake).toHaveBeenCalledTimes(1); +}); + +Deno.test("CSP - does not warn in production", async () => { + // deno-lint-ignore no-explicit-any + using warnSpy = stub(console, "warn", fn(() => {}) as any); + const app = new App() + .use(csp({ useNonce: true })) + .get("/prod-api", () => new Response("ok")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/prod-api"); + await res.body?.cancel(); + + expect(res.headers.get("Content-Security-Policy")).toContain( + "'unsafe-inline'", + ); + expect(warnSpy.fake).not.toHaveBeenCalled(); +});