GioJSdocs
On this page

Content Security Policy

Turn on a nonce-based Content-Security-Policy in gio.toml, roll it out in report-only mode, give your own inline scripts the nonce, and know what it costs.

A Content-Security-Policy (CSP) header tells the browser which scripts, styles, images and connections a page may use. Its strongest form for scripts uses a nonce: a random value, new for every response, that the header lists and every legitimate <script> carries. Markup an attacker manages to inject cannot know the nonce, so it does not run - a cross-site scripting bug becomes a broken widget instead of a stolen session.

In GioJS the policy is one key in gio.toml. The Rust server generates the nonce, puts it in the header, and writes it into every script the framework renders - on fresh renders, cache hits, PPR shells and streamed responses alike. You only add it to inline scripts of your own, with cspNonce().

Why it is opt-in

Most GioJS protections are on by default. CSP is not, for two reasons:

  • Only you know your sources. Analytics, payment widgets, fonts and API origins differ per app. A default policy would either block them or allow so much it protects little.
  • Nonces make every page private. A nonced page is unique to its response, so a shared cache must never replay it. With a {nonce} policy, every HTML page goes out as Cache-Control: private, no-cache without an ETag: a CDN in front of GioJS stops caching pages, and browsers stop getting 304s. GioJS's own page cache keeps working - a cache hit is still served from Rust with a fresh nonce - but every request reaches your server.

If your pages are served from a CDN cache, weigh that against the protection, or use a policy without nonces (below). Turning CSP on or off, or rotating the placeholder, drops the cached pages so none are served with the wrong markup.

Step 1: start in report-only mode

csp_report_only sends the policy as Content-Security-Policy-Report-Only: the browser logs every violation (and can report it to you) but blocks nothing. Write {nonce} where the nonce goes:

gio.toml
[security]
csp_report_only = """
  default-src 'self';
  script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';
  report-uri /api/csp-report
"""

Runs of whitespace, line breaks included, are sent as single spaces. A value that is not a valid header stops the server at startup ([security] csp_report_only: invalid header value). Collect the reports with a route handler. report-uri posts application/csp-report, which req.json() does not accept, so parse the raw body:

app/api/csp-report/route.ts
import type { GioRequest } from '@gio.js/core';

// report-uri posts application/csp-report; the Reporting API
// (report-to) posts application/reports+json.
export function POST(req: GioRequest): Response {
  if (req.body !== null && !req.bodyBase64) {
    try {
      console.warn('csp violation', JSON.parse(req.body));
    } catch {
      // not JSON: ignore it
    }
  }
  return new Response(null, { status: 204 });
}

Browse the app - every page, every widget - and fix what shows up in the browser console and the reports. Consider a rate limit on the report endpoint: any visitor can post to it.

Step 2: give your inline scripts the nonce

Every script GioJS writes already carries the nonce: the hydration bootstrap and its preloads, React's streaming Suspense scripts, the deployment script, the critical-CSS loader, the script <Animate> writes in HTML that never hydrates (the root layout, a page without a client bundle, the not-found and error pages), and the development error overlay. Your own inline scripts need nonce={cspNonce()}. Put them in the root layout, which renders only on the server:

app/layout.tsx
import React from 'react';
import { cspNonce } from '@gio.js/core';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script
          nonce={cspNonce()}
          dangerouslySetInnerHTML={{ __html: "document.documentElement.dataset.theme = localStorage.theme ?? 'light'" }}
        />
        <script nonce={cspNonce()} async src="https://analytics.example.com/script.js" />
      </head>
      <body>{children}</body>
    </html>
  );
}

cspNonce() returns undefined when no policy uses {nonce}, so the layout works with CSP on or off. Its value is a placeholder the server swaps for the real nonce in each response: pass it straight to a nonce attribute and never hash, slice or encode it.

Step 3: allow third-party scripts

With 'strict-dynamic', a script that carries the nonce may load more scripts, and those are trusted too. That is what keeps code splitting and client-side navigation working (each route's chunk is imported by nonced code), and it means a third-party loader only needs the nonce on its own tag, as above. Modern browsers ignore host allowlists in script-src when 'strict-dynamic' is present; other resource types still need their origins listed:

gio.toml
[security]
csp_report_only = """
  default-src 'self';
  script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://images.example-cdn.com;
  connect-src 'self' https://analytics.example.com;
  frame-src https://js.stripe.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self'
"""

Step 4: enforce it

When the reports are quiet, rename the key to csp. You can keep both while you test a stricter version: each gets its own header, and both use the same nonce for a response.

gio.toml
  [security]
- csp_report_only = """
+ csp = """
    default-src 'self';
    script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
bash
$ curl -sI http://localhost:3000/ | grep -i 'content-security\|cache-control'
cache-control: private, no-cache
content-security-policy: default-src 'self'; script-src 'self' 'nonce-SqGohrjsp6nxZ1WcHtH7wbZhgwcjMfZg' 'strict-dynamic'; ...

Styles

Keep style-src 'self' 'unsafe-inline'. React renders style props as style="..." attributes, which no nonce can cover, and <Animate> and link view transitions add inline styles too. Adding a nonce or a hash to style-src makes browsers ignore 'unsafe-inline' and blocks all of them. Script injection is the threat a nonce stops; inline styles are a much smaller one.

Policies without a nonce

A policy without {nonce} is sent exactly as written, and pages stay cacheable by CDNs. GioJS still renders a few inline scripts (the deployment script, React's Suspense scripts on streamed pages, and <Animate> in HTML that never hydrates, such as the root layout), so script-src needs 'unsafe-inline' - without it, deployment-skew reloads, streamed Suspense content and those animations stop working. That leaves little protection against injected scripts, but the other directives still help:

gio.toml
[security]
csp = "script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"

How it works

Pages are cached and PPR shells replayed, so a page cannot be rendered with the nonce of the response that will carry it. The Node worker renders with a secret, random placeholder instead, and the Rust server replaces it with a fresh 192-bit nonce in the headers and body of every dynamic response as it is sent - before compression, and chunk by chunk for streams. The placeholder never reaches a browser, so stored markup can never hold a valid nonce. The details - where the placeholder is kept, when it rotates, and why a response that sets its own Content-Encoding is refused while nonces are on - are in Security.

Good to know

  • Set the policy in [security] csp / csp_report_only only: [security.headers] refuses content-security-policy at startup (use [security] csp instead). A page or route handler that sets its own Content-Security-Policy header keeps it, and an empty value in a [[headers]] rule removes the policy for those paths.
  • Inline event handler attributes (onclick="...") and javascript: URLs cannot carry a nonce and are blocked. React's onClick props are unaffected.
  • Development works the same way: the error overlay and live reload carry the nonce.
  • Static export has no server to set the header or generate nonces; cspNonce() returns undefined there. Set a policy without nonces at your static host.
  • JSON-LD needs no nonce: <JsonLd> renders a non-executable application/ld+json block.

Version history

VersionChanges
v0.1.0-beta.8Introduced [security] csp and csp_report_only with per-response nonces, and cspNonce().