Skip to content

Commit 7b955df

Browse files
committed
fix: append nonce alongside unsafe-inline instead of replacing it
1 parent 86d6cde commit 7b955df

2 files changed

Lines changed: 63 additions & 13 deletions

File tree

packages/fresh/src/middlewares/csp.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ export interface CSPOptions {
1212
csp?: string[];
1313

1414
/**
15-
* If true, replaces 'unsafe-inline' with a nonce-based policy for
16-
* script-src and style-src directives. Fresh automatically injects
15+
* If true, appends a nonce to 'unsafe-inline' entries in the policy
16+
* for script-src and style-src directives. Fresh automatically injects
1717
* nonce attributes on inline `<script>` and `<style>` tags during
18-
* server rendering, so this option locks down the policy to only
19-
* allow those Fresh-rendered inline elements.
18+
* server rendering, so this option allows those Fresh-rendered inline
19+
* elements while keeping 'unsafe-inline' as a fallback for older
20+
* browsers that don't support CSP Level 3 nonces.
21+
*
22+
* Note: 'unsafe-inline' is kept in the policy alongside the nonce.
23+
* If you want only the nonce, omit 'unsafe-inline' from your
24+
* directives.
2025
*/
2126
useNonce?: boolean;
2227
}
@@ -124,7 +129,10 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
124129
const spaceIdx = d.indexOf(" ");
125130
const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx);
126131
if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) {
127-
return d.replaceAll("'unsafe-inline'", `'nonce-${nonce}'`);
132+
return d.replace(
133+
"'unsafe-inline'",
134+
`'unsafe-inline' 'nonce-${nonce}'`,
135+
);
128136
}
129137
return d;
130138
});

packages/fresh/src/middlewares/csp_test.tsx

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ Deno.test("CSP - GET report only", async () => {
8888
);
8989
});
9090

91-
Deno.test("CSP - useNonce replaces unsafe-inline with nonce", async () => {
91+
Deno.test("CSP - useNonce appends nonce alongside unsafe-inline", async () => {
9292
const app = new App()
9393
.use(csp({ useNonce: true }))
9494
.get("/", (ctx) => {
@@ -109,10 +109,14 @@ Deno.test("CSP - useNonce replaces unsafe-inline with nonce", async () => {
109109
const html = await res.text();
110110
const cspHeader = res.headers.get("Content-Security-Policy")!;
111111

112-
// Should contain nonce directive, not unsafe-inline
113-
expect(cspHeader).not.toContain("'unsafe-inline'");
114-
expect(cspHeader).toMatch(/script-src 'self' 'nonce-[a-f0-9]+'/);
115-
expect(cspHeader).toMatch(/style-src 'self' 'nonce-[a-f0-9]+'/);
112+
// Should contain both unsafe-inline and nonce
113+
expect(cspHeader).toContain("'unsafe-inline'");
114+
expect(cspHeader).toMatch(
115+
/script-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
116+
);
117+
expect(cspHeader).toMatch(
118+
/style-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
119+
);
116120

117121
// Nonce should not leak as a response header
118122
expect(res.headers.get("X-Fresh-Nonce")).toBeNull();
@@ -237,7 +241,7 @@ Deno.test("CSP - nonce does not leak as header without CSP middleware", async ()
237241
expect((res as any)[NONCE_SYMBOL]).toBeDefined();
238242
});
239243

240-
Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => {
244+
Deno.test("CSP - useNonce appends nonce alongside unsafe-inline in default-src", async () => {
241245
const app = new App()
242246
.use(csp({
243247
useNonce: true,
@@ -257,6 +261,44 @@ Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => {
257261
await res.body?.cancel();
258262
const cspHeader = res.headers.get("Content-Security-Policy")!;
259263

260-
// default-src should have nonce, not unsafe-inline
261-
expect(cspHeader).toMatch(/default-src 'self' 'nonce-[a-f0-9]+'/);
264+
// default-src should contain both unsafe-inline and nonce
265+
expect(cspHeader).toMatch(
266+
/default-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/,
267+
);
268+
});
269+
270+
Deno.test("CSP - nonce only added when unsafe-inline is present in directive", async () => {
271+
const app = new App()
272+
.use(csp({
273+
useNonce: true,
274+
csp: ["script-src 'self'"],
275+
}))
276+
.get("/", (ctx) => {
277+
return ctx.render(
278+
<html>
279+
<head />
280+
<body>hello</body>
281+
</html>,
282+
);
283+
});
284+
285+
const server = new FakeServer(app.handler());
286+
const res = await server.get("/");
287+
await res.body?.cancel();
288+
const cspHeader = res.headers.get("Content-Security-Policy")!;
289+
290+
// script-src (user override, no 'unsafe-inline'): no nonce, no unsafe-inline
291+
const scriptSrc = cspHeader.split("; ").find((d) =>
292+
d.startsWith("script-src")
293+
)!;
294+
expect(scriptSrc).toEqual("script-src 'self'");
295+
expect(scriptSrc).not.toContain("'unsafe-inline'");
296+
expect(scriptSrc).not.toMatch(/'nonce-/);
297+
298+
// style-src (default, has 'unsafe-inline'): nonce appended alongside
299+
const styleSrc = cspHeader.split("; ").find((d) =>
300+
d.startsWith("style-src")
301+
)!;
302+
expect(styleSrc).toContain("'unsafe-inline'");
303+
expect(styleSrc).toMatch(/'nonce-[a-f0-9]+'/);
262304
});

0 commit comments

Comments
 (0)