CSP
Restrict the scripts a page runs with a Content-Security-Policy.
On this page
The framework’s scripts
The framework sets no policy; the application writes it. The framework only makes the inline scripts it writes into the HTML allowable by hash or nonce: the payload embedded for hydration, and React’s own script.
Static mode
In static mode, the policy goes to the csp option of framework() and into each page’s <meta>. A static file is the same for every request, so a nonce cannot be used. nonce() exists in server mode only.
The csp option
vite.config.tsimport { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
framework({
mode: 'static',
csp: {
'script-src': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'none'"],
},
}),
],
});Give csp an array of sources per directive. Each page gets a <meta http-equiv="Content-Security-Policy"> first in its <head>. Without csp, no <meta> is written.
The build adds the hashes of the framework’s inline scripts to script-src, page by page.
When only default-src is given, script-src is made from it first. When script-src-elem is given, the hashes go there too. With none of them, scripts are not restricted, and no hash is added.
Your own scripts
vite.config.tsimport { colorSchemeScriptHash } from '@k8ordo/color-scheme';
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
framework({
mode: 'static',
csp: {
'script-src': ["'self'", await colorSchemeScriptHash()],
'object-src': ["'none'"],
'base-uri': ["'none'"],
},
}),
],
});The application’s own inline scripts are allowed by writing their hashes into the policy. On line 10, colorSchemeScriptHash() returns the hash of the @k8ordo/color-scheme script.
If ColorSchemeProvider is given a defaultPreference, pass the same value to colorSchemeScriptHash(). The value changes the script and its hash.
Every inline script whose hash is not in the policy is blocked. A script injected through the content does not run either.
Policy errors
The framework’s module script is allowed by 'self'. 'strict-dynamic' makes browsers ignore 'self', so 'strict-dynamic' in script-src (default-src without it) or script-src-elem is an error.
frame-ancestors, report-uri and sandbox are errors too. A <meta> ignores them, so set them as headers at the host.
Pitfall
framework() throws as soon as it is called, with an error that starts the "csp" option cannot go into a page's <meta> as it is: and lists one problem per line.
Server mode
In server mode, every response gets a new nonce, and the root guard.ts writes the policy as a header.
The policy in guard.ts
src/routes/guard.tsimport { nonce, responseHeaders } from '@k8ordo/framework/server';
export default function guard() {
const scripts = [`'nonce-${nonce()}'`, "'strict-dynamic'"];
if (import.meta.env.DEV) scripts.push("'unsafe-eval'");
const policy = [
`script-src ${scripts.join(' ')}`,
"object-src 'none'",
"base-uri 'none'",
].join('; ');
responseHeaders().set('content-security-policy', policy);
}nonce() from @k8ordo/framework/server returns the nonce of the response. Line 4 writes it into the policy, and line 11 sets the header.
Note
Line 5 adds 'unsafe-eval' under vite dev only. React’s development build calls eval(), and without it every page load logs an error to the console. A production build never calls it, and the line is dropped from it.
The root guard.ts runs before the response of a page, its payload, a Server Action and a route.ts. The 404 for a URL no route matched carries the same policy.
The nonce is on the module script too. With 'strict-dynamic', that script can load the rest of the client code.
Scripts that carry the nonce
src/routes/layout.tsximport { ColorSchemeProvider } from '@k8ordo/color-scheme';
import { nonce } from '@k8ordo/framework/server';
import type { ReactNode } from 'react';
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ColorSchemeProvider nonce={nonce()}>
{children}
</ColorSchemeProvider>
</body>
</html>
);
}The application’s own inline scripts carry the same nonce. For @k8ordo/color-scheme, pass nonce={nonce()} to ColorSchemeProvider in the root layout.
nonce() returns the same value anywhere while the request is being answered. It can be called during the render too.
Called outside a request, at a module’s top level for example, it errors with a message that starts with nonce() needs a request.
Nonces and caches
Keep a response that carries a nonce out of shared caches such as a CDN. If the same nonce is served again and again, an attacker who learned it can inject a script that carries it.