@k8ordo/framework

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.ts
import { 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.ts
import { 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.

Warning

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.ts
import { 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.

Information

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.tsx
import { 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.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2