Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/latest/plugins/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ When `useNonce` is enabled:

- Fresh automatically injects a unique `nonce` attribute onto every inline
`<script>` and `<style>` tag during server rendering.
- The CSP header replaces `'unsafe-inline'` with `'nonce-{value}'` in
`script-src`, `style-src`, `default-src`, `script-src-elem`, `style-src-elem`,
and `style-src-attr` directives.
- The CSP header **appends** `'nonce-{value}'` to `script-src`, `style-src`,
`default-src`, `script-src-elem`, `style-src-elem`, and `style-src-attr`
directives. Any `'unsafe-inline'` you (or the defaults) wrote is preserved
alongside the nonce — modern browsers prefer the nonce and ignore
`'unsafe-inline'` when both are present, while older browsers fall back to
`'unsafe-inline'`. This matches the
[strict-CSP fallback pattern recommended
by web.dev](https://web.dev/articles/strict-csp#fallbacks).
- Each request gets a fresh nonce, so the value cannot be predicted by an
attacker.
- Non-rendered responses (e.g. API routes returning JSON) fall back to
Expand Down
21 changes: 14 additions & 7 deletions packages/fresh/src/middlewares/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export interface CSPOptions {
csp?: string[];

/**
* If true, replaces 'unsafe-inline' with a nonce-based policy for
* script-src and style-src directives. Fresh automatically injects
* nonce attributes on inline `<script>` and `<style>` tags during
* server rendering, so this option locks down the policy to only
* allow those Fresh-rendered inline elements.
* If true, appends `'nonce-{value}'` to script-src and style-src directives
* without stripping any `'unsafe-inline'` the user (or the defaults) wrote.
* Fresh automatically injects the nonce on inline `<script>` and `<style>`
* tags during server rendering, so modern browsers will permit them via the
* nonce. Older browsers that ignore `'nonce-...'` fall back to the user's
* `'unsafe-inline'`, matching the strict-CSP fallback pattern recommended by
* web.dev (and used in production by e.g. YouTube).
*/
useNonce?: boolean;
}
Expand Down Expand Up @@ -123,8 +125,13 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
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'")) {
return d.replaceAll("'unsafe-inline'", `'nonce-${nonce}'`);
if (INLINE_DIRECTIVES.has(name)) {
// Append the nonce alongside whatever the user wrote (including any
// `'unsafe-inline'` fallback for older browsers). Modern browsers
// prefer the nonce and ignore `'unsafe-inline'` when both are
// present; older browsers that don't understand the nonce fall back
// to `'unsafe-inline'`.
return `${d} 'nonce-${nonce}'`;
}
return d;
});
Expand Down
91 changes: 83 additions & 8 deletions packages/fresh/src/middlewares/csp_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Deno.test("CSP - GET report only", async () => {
);
});

Deno.test("CSP - useNonce replaces unsafe-inline with nonce", async () => {
Deno.test("CSP - useNonce appends nonce alongside existing directives", async () => {
const app = new App()
.use(csp({ useNonce: true }))
.get("/", (ctx) => {
Expand All @@ -109,10 +109,15 @@ Deno.test("CSP - useNonce replaces unsafe-inline with nonce", async () => {
const html = await res.text();
const cspHeader = res.headers.get("Content-Security-Policy")!;

// Should contain nonce directive, not unsafe-inline
expect(cspHeader).not.toContain("'unsafe-inline'");
expect(cspHeader).toMatch(/script-src 'self' 'nonce-[a-f0-9]+'/);
expect(cspHeader).toMatch(/style-src 'self' 'nonce-[a-f0-9]+'/);
// nonce should be appended to default directives
expect(cspHeader).toMatch(
/script-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
);
expect(cspHeader).toMatch(
/style-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
);
// existing 'unsafe-inline' from defaults must be preserved (older-browser fallback)
expect(cspHeader).toContain("'unsafe-inline'");

// Nonce should not leak as a response header
expect(res.headers.get("X-Fresh-Nonce")).toBeNull();
Expand Down Expand Up @@ -151,6 +156,74 @@ Deno.test("CSP - useNonce injects nonce on inline script tags", async () => {
expect(html).toContain(`nonce="${nonce}"`);
});

Deno.test("CSP - useNonce preserves user-supplied unsafe-inline as fallback", async () => {
// web.dev's strict-CSP pattern: user writes `'unsafe-inline'` so older
// browsers that ignore nonces still work, while modern browsers use the
// nonce. See #3813.
const app = new App()
.use(csp({
useNonce: true,
csp: [
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
],
}))
.get("/", (ctx) => {
return ctx.render(
<html>
<head />
<body>hello</body>
</html>,
);
});

const server = new FakeServer(app.handler());
const res = await server.get("/");
await res.body?.cancel();
const cspHeader = res.headers.get("Content-Security-Policy")!;

// User's 'unsafe-inline' must still be present
expect(cspHeader).toContain("script-src 'self' 'unsafe-inline'");
expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'");
// And the nonce must also be appended
expect(cspHeader).toMatch(
/script-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
);
expect(cspHeader).toMatch(
/style-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
);
});

Deno.test("CSP - useNonce appends nonce to inline directive that lacks unsafe-inline", async () => {
// Strict-CSP setup: user writes the inline directive without 'unsafe-inline'.
// The nonce alone is sufficient because Fresh injects nonces on inline tags.
const app = new App()
.use(csp({
useNonce: true,
csp: [
"script-src 'self'",
"style-src 'self'",
],
}))
.get("/", (ctx) => {
return ctx.render(
<html>
<head />
<body>hello</body>
</html>,
);
});

const server = new FakeServer(app.handler());
const res = await server.get("/");
await res.body?.cancel();
const cspHeader = res.headers.get("Content-Security-Policy")!;

// nonce must still be appended even when 'unsafe-inline' was absent
expect(cspHeader).toMatch(/script-src 'self' 'nonce-[a-f0-9]+'/);
expect(cspHeader).toMatch(/style-src 'self' 'nonce-[a-f0-9]+'/);
});

Deno.test("CSP - useNonce with non-rendered response falls back to unsafe-inline", async () => {
const app = new App()
.use(csp({ useNonce: true }))
Expand Down Expand Up @@ -237,7 +310,7 @@ Deno.test("CSP - nonce does not leak as header without CSP middleware", async ()
expect((res as any)[NONCE_SYMBOL]).toBeDefined();
});

Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => {
Deno.test("CSP - useNonce appends nonce to user-provided default-src", async () => {
const app = new App()
.use(csp({
useNonce: true,
Expand All @@ -257,6 +330,8 @@ Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => {
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]+'/);
// default-src should keep its 'unsafe-inline' AND gain the nonce
expect(cspHeader).toMatch(
/default-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
);
});