diff --git a/docs/latest/plugins/csp.md b/docs/latest/plugins/csp.md index 5ef1b6e5dc9..adf7e6b6a8d 100644 --- a/docs/latest/plugins/csp.md +++ b/docs/latest/plugins/csp.md @@ -25,6 +25,49 @@ const app = new App() .get("/", () => new Response("hello")); ``` +## Nonce-based CSP + +For stricter security, you can use nonce-based CSP instead of `'unsafe-inline'`. +This ensures only inline ` + + , + ); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + const cspHeader = res.headers.get("Content-Security-Policy")!; + + const nonceMatch = cspHeader.match(/nonce-([a-f0-9]+)/); + expect(nonceMatch).not.toBeNull(); + const nonce = nonceMatch![1]; + + // Inline script should have the nonce + expect(html).toContain(`nonce="${nonce}"`); +}); + +Deno.test("CSP - useNonce with non-rendered response falls back to unsafe-inline", async () => { + const app = new App() + .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(); + const cspHeader = res.headers.get("Content-Security-Policy")!; + + // Non-rendered response has no nonce, so unsafe-inline stays + expect(cspHeader).toContain("'unsafe-inline'"); +}); + +Deno.test("CSP - useNonce generates unique nonce per request", async () => { + const app = new App() + .use(csp({ useNonce: true })) + .get("/", (ctx) => { + return ctx.render( + + + hello + , + ); + }); + + const server = new FakeServer(app.handler()); + const res1 = await server.get("/"); + await res1.body?.cancel(); + const res2 = await server.get("/"); + await res2.body?.cancel(); + + const nonce1 = res1.headers.get("Content-Security-Policy")!.match( + /nonce-([a-f0-9]+)/, + )![1]; + const nonce2 = res2.headers.get("Content-Security-Policy")!.match( + /nonce-([a-f0-9]+)/, + )![1]; + + expect(nonce1).not.toEqual(nonce2); +}); + +Deno.test("CSP - existing nonce on tag is preserved", async () => { + const app = new App() + .use(csp({ useNonce: true })) + .get("/", (ctx) => { + return ctx.render( + + + + + hello + , + ); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + + // The explicit nonce should be preserved, not overwritten + expect(html).toContain('nonce="custom-nonce"'); +}); + +Deno.test("CSP - nonce does not leak as header without CSP middleware", async () => { + const app = new App() + .get("/", (ctx) => { + return ctx.render( + + + hello + , + ); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + await res.body?.cancel(); + + // No CSP middleware — nonce must not appear as a response header + expect(res.headers.get("X-Fresh-Nonce")).toBeNull(); + // But it should still be available via the symbol for middleware that needs it + // deno-lint-ignore no-explicit-any + expect((res as any)[NONCE_SYMBOL]).toBeDefined(); +}); + +Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => { + const app = new App() + .use(csp({ + useNonce: true, + csp: ["default-src 'self' 'unsafe-inline'"], + })) + .get("/", (ctx) => { + return ctx.render( + + + hello + , + ); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + await res.body?.cancel(); + const cspHeader = res.headers.get("Content-Security-Policy")!; + + // default-src should have nonce, not unsafe-inline + expect(cspHeader).toMatch(/default-src 'self' 'nonce-[a-f0-9]+'/); +}); diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index 320439bb97e..77213206904 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -132,6 +132,18 @@ options[OptionsType.VNODE] = (vnode) => { ); } } else if (typeof vnode.type === "string") { + // Auto-inject nonce onto inline script/style tags + if ( + RENDER_STATE !== null && + (vnode.type === "script" || vnode.type === "style") + ) { + // deno-lint-ignore no-explicit-any + const props = vnode.props as any; + if (!props.nonce) { + props.nonce = RENDER_STATE.nonce; + } + } + if (vnode.type === "body") { const scripts = h(FreshScripts, null); if (vnode.props.children == null) {