Skip to content
Merged
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
43 changes: 43 additions & 0 deletions docs/latest/plugins/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` and `<style>` tags rendered by Fresh are
allowed to execute.

```ts main.ts
import { csp } from "fresh";

const app = new App()
.use(csp({ useNonce: true }))
.get("/", (ctx) => {
return ctx.render(
<html>
<head>
<style>{"body { color: red; }"}</style>
</head>
<body>
<h1>Hello</h1>
</body>
</html>,
);
});
```

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.
- 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
`'unsafe-inline'` since there is no rendering step to generate a nonce.

> [warn]: If you set an explicit `nonce` attribute on a tag, it will be
> preserved in the HTML, but the CSP header will only contain the
> Fresh-generated nonce. The browser will block the tag unless its nonce matches
> the one in the CSP header. To avoid this, let Fresh manage nonces
> automatically.

## Options

See the [API docs](https://jsr.io/@fresh/core/doc/~/csp) for a list of all
Expand Down
10 changes: 9 additions & 1 deletion packages/fresh/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
RenderState,
setRenderState,
} from "./runtime/server/preact_hooks.ts";
import { NONCE_SYMBOL } from "./middlewares/csp.ts";
import { DEV_ERROR_OVERLAY_URL, PARTIAL_SEARCH_PARAM } from "./constants.ts";
import { tracer } from "./otel.ts";
import {
Expand Down Expand Up @@ -285,6 +286,7 @@ export class Context<State> {
headers.set("X-Fresh-Id", partialId);
}

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

renderNonce = state.nonce;
state.clear();
setRenderState(null);

span.end();
}
});
return new Response(html, responseInit);
const response = new Response(html, responseInit);
// Expose the nonce to CSP middleware via a symbol so it never
// leaks as a response header.
// deno-lint-ignore no-explicit-any
(response as any)[NONCE_SYMBOL] = renderNonce;
return response;
}

/**
Expand Down
72 changes: 67 additions & 5 deletions packages/fresh/src/middlewares/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,33 @@ export interface CSPOptions {

/** Additional CSP directives to add or override the defaults */
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.
*/
useNonce?: boolean;
}

/**
* Symbol used to pass the render nonce from ctx.render() to the CSP
* middleware without exposing it as a response header.
*/
export const NONCE_SYMBOL: unique symbol = Symbol.for("__freshNonce");

/** Directives that may contain 'unsafe-inline' for script/style sources */
const INLINE_DIRECTIVES = new Set([
"script-src",
"style-src",
"script-src-elem",
"style-src-elem",
"style-src-attr",
"default-src",
]);

/**
* Middleware to set Content-Security-Policy headers
*
Expand All @@ -27,12 +52,18 @@ export interface CSPOptions {
* ],
* }));
* ```
*
* @example Nonce-based CSP
* ```ts
* app.use(csp({ useNonce: true }));
* ```
*/
export function csp<State>(options: CSPOptions = {}): Middleware<State> {
const {
reportOnly = false,
reportTo,
csp = [],
useNonce = false,
} = options;

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

const headerName = reportOnly
? "Content-Security-Policy-Report-Only"
: "Content-Security-Policy";

if (!useNonce) {
// Static CSP — no per-request nonce
const cspString = cspDirectives.join("; ");
return async (ctx) => {
const res = await ctx.next();
res.headers.set(headerName, cspString);
if (reportTo) {
res.headers.set("Reporting-Endpoints", `csp-endpoint="${reportTo}"`);
}
return res;
};
}

// Nonce-based CSP — replace 'unsafe-inline' with nonce per request
return async (ctx) => {
const res = await ctx.next();
const headerName = reportOnly
? "Content-Security-Policy-Report-Only"
: "Content-Security-Policy";
res.headers.set(headerName, cspString);
// deno-lint-ignore no-explicit-any
const nonce = (res as any)[NONCE_SYMBOL] as string | undefined;

let directives: string[];
if (nonce) {
directives = cspDirectives.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}'`);
}
return d;
});
} else {
directives = cspDirectives;
}

res.headers.set(headerName, directives.join("; "));
if (reportTo) {
res.headers.set("Reporting-Endpoints", `csp-endpoint="${reportTo}"`);
}
Expand Down
58 changes: 0 additions & 58 deletions packages/fresh/src/middlewares/csp_test.ts

This file was deleted.

Loading
Loading