Skip to content

Commit 26912b3

Browse files
committed
fix(csp): warn in development when a response has no nonce
1 parent 86d6cde commit 26912b3

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

packages/fresh/src/middlewares/csp.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
112112
};
113113
}
114114

115+
const warnedNoncelessPaths = new Set<string>();
116+
117+
function warnMissingNonce(pathname: string) {
118+
if (warnedNoncelessPaths.has(pathname)) return;
119+
warnedNoncelessPaths.add(pathname);
120+
// deno-lint-ignore no-console
121+
console.warn(
122+
`🍋 %c[WARNING] CSP: "${pathname}" responded without a nonce, so 'unsafe-inline' was kept. Only ctx.render() sets a nonce.`,
123+
"color:rgb(251, 184, 0)",
124+
);
125+
}
126+
115127
// Nonce-based CSP — replace 'unsafe-inline' with nonce per request
116128
return async (ctx) => {
117129
const res = await ctx.next();
@@ -129,6 +141,9 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
129141
return d;
130142
});
131143
} else {
144+
if (ctx.config.mode === "development") {
145+
warnMissingNonce(ctx.url.pathname);
146+
}
132147
directives = merged;
133148
}
134149

packages/fresh/src/middlewares/csp_test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { expect } from "@std/expect/expect";
2+
import { fn } from "@std/expect";
3+
import { stub } from "@std/testing/mock";
24
import { App } from "../app.ts";
35
import { csp } from "./csp.ts";
46
import { FakeServer } from "../test_utils.ts";
@@ -260,3 +262,57 @@ Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => {
260262
// default-src should have nonce, not unsafe-inline
261263
expect(cspHeader).toMatch(/default-src 'self' 'nonce-[a-f0-9]+'/);
262264
});
265+
266+
Deno.test("CSP - warns in development when a response has no nonce", async () => {
267+
// deno-lint-ignore no-explicit-any
268+
using warnSpy = stub(console, "warn", fn(() => {}) as any);
269+
const app = new App({ mode: "development" })
270+
.use(csp({ useNonce: true }))
271+
.get("/api", () => new Response(JSON.stringify({ ok: true })));
272+
273+
const server = new FakeServer(app.handler());
274+
const res = await server.get("/api");
275+
await res.body?.cancel();
276+
277+
expect(res.headers.get("Content-Security-Policy")).toContain(
278+
"'unsafe-inline'",
279+
);
280+
expect(warnSpy.fake).toHaveBeenCalledTimes(1);
281+
expect(warnSpy.fake).toHaveBeenLastCalledWith(
282+
`🍋 %c[WARNING] CSP: "/api" responded without a nonce, so 'unsafe-inline' was kept. Only ctx.render() sets a nonce.`,
283+
expect.any(String),
284+
);
285+
});
286+
287+
Deno.test("CSP - warns once per path, not once per request", async () => {
288+
// deno-lint-ignore no-explicit-any
289+
using warnSpy = stub(console, "warn", fn(() => {}) as any);
290+
const app = new App({ mode: "development" })
291+
.use(csp({ useNonce: true }))
292+
.get("/repeated", () => new Response("ok"));
293+
294+
const server = new FakeServer(app.handler());
295+
for (let i = 0; i < 3; i++) {
296+
const res = await server.get("/repeated");
297+
await res.body?.cancel();
298+
}
299+
300+
expect(warnSpy.fake).toHaveBeenCalledTimes(1);
301+
});
302+
303+
Deno.test("CSP - does not warn in production", async () => {
304+
// deno-lint-ignore no-explicit-any
305+
using warnSpy = stub(console, "warn", fn(() => {}) as any);
306+
const app = new App()
307+
.use(csp({ useNonce: true }))
308+
.get("/prod-api", () => new Response("ok"));
309+
310+
const server = new FakeServer(app.handler());
311+
const res = await server.get("/prod-api");
312+
await res.body?.cancel();
313+
314+
expect(res.headers.get("Content-Security-Policy")).toContain(
315+
"'unsafe-inline'",
316+
);
317+
expect(warnSpy.fake).not.toHaveBeenCalled();
318+
});

0 commit comments

Comments
 (0)