Skip to content

Commit dc23248

Browse files
bartlomiejuclaude
andauthored
feat: add nonce support for inline style and script tags in CSP (#3709)
## Summary - Auto-inject `nonce` attribute onto inline `<script>` and `<style>` tags during server rendering - Add `useNonce` option to the CSP middleware that replaces `'unsafe-inline'` with per-request `'nonce-{value}'` directives - Expose render nonce via `X-Fresh-Nonce` response header (internal, stripped by CSP middleware) ## Usage ```ts app.use(csp({ useNonce: true })); ``` This locks down the CSP policy so only Fresh-rendered inline scripts/styles are allowed. Each request gets a unique nonce. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 566a6ce commit dc23248

6 files changed

Lines changed: 364 additions & 64 deletions

File tree

docs/latest/plugins/csp.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,49 @@ const app = new App()
2525
.get("/", () => new Response("hello"));
2626
```
2727

28+
## Nonce-based CSP
29+
30+
For stricter security, you can use nonce-based CSP instead of `'unsafe-inline'`.
31+
This ensures only inline `<script>` and `<style>` tags rendered by Fresh are
32+
allowed to execute.
33+
34+
```ts main.ts
35+
import { csp } from "fresh";
36+
37+
const app = new App()
38+
.use(csp({ useNonce: true }))
39+
.get("/", (ctx) => {
40+
return ctx.render(
41+
<html>
42+
<head>
43+
<style>{"body { color: red; }"}</style>
44+
</head>
45+
<body>
46+
<h1>Hello</h1>
47+
</body>
48+
</html>,
49+
);
50+
});
51+
```
52+
53+
When `useNonce` is enabled:
54+
55+
- Fresh automatically injects a unique `nonce` attribute onto every inline
56+
`<script>` and `<style>` tag during server rendering.
57+
- The CSP header replaces `'unsafe-inline'` with `'nonce-{value}'` in
58+
`script-src`, `style-src`, `default-src`, `script-src-elem`, `style-src-elem`,
59+
and `style-src-attr` directives.
60+
- Each request gets a fresh nonce, so the value cannot be predicted by an
61+
attacker.
62+
- Non-rendered responses (e.g. API routes returning JSON) fall back to
63+
`'unsafe-inline'` since there is no rendering step to generate a nonce.
64+
65+
> [warn]: If you set an explicit `nonce` attribute on a tag, it will be
66+
> preserved in the HTML, but the CSP header will only contain the
67+
> Fresh-generated nonce. The browser will block the tag unless its nonce matches
68+
> the one in the CSP header. To avoid this, let Fresh manage nonces
69+
> automatically.
70+
2871
## Options
2972

3073
See the [API docs](https://jsr.io/@fresh/core/doc/~/csp) for a list of all

packages/fresh/src/context.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
RenderState,
1818
setRenderState,
1919
} from "./runtime/server/preact_hooks.ts";
20+
import { NONCE_SYMBOL } from "./middlewares/csp.ts";
2021
import { DEV_ERROR_OVERLAY_URL, PARTIAL_SEARCH_PARAM } from "./constants.ts";
2122
import { tracer } from "./otel.ts";
2223
import {
@@ -285,6 +286,7 @@ export class Context<State> {
285286
headers.set("X-Fresh-Id", partialId);
286287
}
287288

289+
let renderNonce = "";
288290
const html = tracer.startActiveSpan("render", (span) => {
289291
span.setAttribute("fresh.span_type", "render");
290292
const state = new RenderState(
@@ -376,13 +378,19 @@ export class Context<State> {
376378
headers.append("Link", link);
377379
}
378380

381+
renderNonce = state.nonce;
379382
state.clear();
380383
setRenderState(null);
381384

382385
span.end();
383386
}
384387
});
385-
return new Response(html, responseInit);
388+
const response = new Response(html, responseInit);
389+
// Expose the nonce to CSP middleware via a symbol so it never
390+
// leaks as a response header.
391+
// deno-lint-ignore no-explicit-any
392+
(response as any)[NONCE_SYMBOL] = renderNonce;
393+
return response;
386394
}
387395

388396
/**

packages/fresh/src/middlewares/csp.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,33 @@ export interface CSPOptions {
1010

1111
/** Additional CSP directives to add or override the defaults */
1212
csp?: string[];
13+
14+
/**
15+
* If true, replaces 'unsafe-inline' with a nonce-based policy for
16+
* script-src and style-src directives. Fresh automatically injects
17+
* 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.
20+
*/
21+
useNonce?: boolean;
1322
}
1423

24+
/**
25+
* Symbol used to pass the render nonce from ctx.render() to the CSP
26+
* middleware without exposing it as a response header.
27+
*/
28+
export const NONCE_SYMBOL: unique symbol = Symbol.for("__freshNonce");
29+
30+
/** Directives that may contain 'unsafe-inline' for script/style sources */
31+
const INLINE_DIRECTIVES = new Set([
32+
"script-src",
33+
"style-src",
34+
"script-src-elem",
35+
"style-src-elem",
36+
"style-src-attr",
37+
"default-src",
38+
]);
39+
1540
/**
1641
* Middleware to set Content-Security-Policy headers
1742
*
@@ -27,12 +52,18 @@ export interface CSPOptions {
2752
* ],
2853
* }));
2954
* ```
55+
*
56+
* @example Nonce-based CSP
57+
* ```ts
58+
* app.use(csp({ useNonce: true }));
59+
* ```
3060
*/
3161
export function csp<State>(options: CSPOptions = {}): Middleware<State> {
3262
const {
3363
reportOnly = false,
3464
reportTo,
3565
csp = [],
66+
useNonce = false,
3667
} = options;
3768

3869
const defaultCsp = [
@@ -56,14 +87,45 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
5687
cspDirectives.push(`report-to csp-endpoint`);
5788
cspDirectives.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it
5889
}
59-
const cspString = cspDirectives.join("; ");
6090

91+
const headerName = reportOnly
92+
? "Content-Security-Policy-Report-Only"
93+
: "Content-Security-Policy";
94+
95+
if (!useNonce) {
96+
// Static CSP — no per-request nonce
97+
const cspString = cspDirectives.join("; ");
98+
return async (ctx) => {
99+
const res = await ctx.next();
100+
res.headers.set(headerName, cspString);
101+
if (reportTo) {
102+
res.headers.set("Reporting-Endpoints", `csp-endpoint="${reportTo}"`);
103+
}
104+
return res;
105+
};
106+
}
107+
108+
// Nonce-based CSP — replace 'unsafe-inline' with nonce per request
61109
return async (ctx) => {
62110
const res = await ctx.next();
63-
const headerName = reportOnly
64-
? "Content-Security-Policy-Report-Only"
65-
: "Content-Security-Policy";
66-
res.headers.set(headerName, cspString);
111+
// deno-lint-ignore no-explicit-any
112+
const nonce = (res as any)[NONCE_SYMBOL] as string | undefined;
113+
114+
let directives: string[];
115+
if (nonce) {
116+
directives = cspDirectives.map((d) => {
117+
const spaceIdx = d.indexOf(" ");
118+
const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx);
119+
if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) {
120+
return d.replaceAll("'unsafe-inline'", `'nonce-${nonce}'`);
121+
}
122+
return d;
123+
});
124+
} else {
125+
directives = cspDirectives;
126+
}
127+
128+
res.headers.set(headerName, directives.join("; "));
67129
if (reportTo) {
68130
res.headers.set("Reporting-Endpoints", `csp-endpoint="${reportTo}"`);
69131
}

packages/fresh/src/middlewares/csp_test.ts

Lines changed: 0 additions & 58 deletions
This file was deleted.

0 commit comments

Comments
 (0)