Tutorial 33 of 36 · for anyone adding a Content-Security-Policy to a site · Markdown

Set a content security policy

You'll write a Content-Security-Policy, find out what it blocks before it blocks anything, read each violation in the dev panel and see why nonces don't work for inline scripts here.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Write the policy as directives#

config/app.toml
[document.csp]
default-src = ["'self'"]
script-src = ["'self'"]
style-src = ["'self'", "'unsafe-inline'"]
img-src = ["'self'", "data:"]
base-uri = ["'none'"]
object-src = ["'none'"]
frame-ancestors = ["'self'"]

Each directive is a list of sources and the host composes the header from them.

A policy governs every script on the page, so a tag that loaded fine yesterday stops the moment a policy exists. Run it in report-only mode first, as the next section shows.

Learn what it would block first#

An application fsr new made already starts here. Its config/app.toml carries:

config/app.toml
# Reported, not enforced. Rename the table to [document.csp] to enforce it.
[document.csp_report_only]
default-src = ["'self'"]
script-src = ["'self'"]
base-uri = ["'none'"]
object-src = ["'none'"]
frame-ancestors = ["'self'"]

Add an origin the same way you would to an enforced policy:

config/app.toml
[document.csp_report_only]
default-src = ["'self'"]
script-src = ["'self'", "https://www.googletagmanager.com"]

It takes the same directives and is sent as Content-Security-Policy-Report-Only. The browser reports violations and enforces nothing. Both keys may be set at once, so a deployment can enforce the policy it is sure of and report against the one it wants next.

Use this for anything whose origins you do not control. An allowlist covers analytics, whose origins are stable. It cannot cover an ad network, which injects scripts from origins that change per impression.

Read violations in the dev panel#

Under fsr dev, every violation the page meets shows up in a panel at the top right of the page, with the change that would admit it:

fsr dev: the content security policy would block 2 things

reported an inline script (script-src-elem)
  move it into a file the page loads or add its hash to script-src in [document.csp_report_only]

reported https://example.com (img-src)
  add img-src = ["'self'", "https://example.com"] to [document.csp_report_only], which carries over what default-src allowed

reported means a report-only policy let it through. Under [document.csp] the same lines say blocked. The second suggestion lists 'self' because this policy declares no img-src: declaring one replaces default-src for images, so it has to carry default-src's sources over or same-origin images stop loading. Click the panel to hide it.

The import map's hash#

The document carries an inline import map, so script-src gets a sha256 of it:

script-src 'self' 'sha256-svThEqLXIp8mujEwI5OYbQV5w06ndiss2LoQLZGpW0Q='

The host computes it from the text it actually emits.

A hash in script-src makes 'unsafe-inline' ignored. The spec requires this, so you cannot add 'unsafe-inline' to permit some other inline script while a hash is present. The browser ignores it.

fsr build and fsr dev print a csp row when a policy falls into this, so you see it on the build that introduced it:

csp       `document.csp` names `'unsafe-inline'` in `script-src` while the document carries an import map

The hash has to be taken from the emitted text. Any rewrite of the import map has to happen before the policy reads it. Get that order wrong and the header carries a hash of a document that no longer exists, so the browser blocks the import map.

Nonces#

A nonce is the usual answer to inline scripts, but it does not work here.

A nonce must be unique per response. A prerendered document is a file written once and served to everybody, so its nonce is a constant that everybody's browser sees, which defeats the purpose. The render memo has the same problem: a memoized subtree is rendered once and replayed, nonce included.

So the host issues nonces under dev, where neither prerendering nor the memo is in play. Production uses hashes. If you want a nonce in production, move that script out of the document so it is no longer inline.

Extension violations in the console#

Turn a policy on, open the site in your own browser and the console fills with violations you did not cause:

CSP: blocked an inline script (script-src-elem) ... at inpage.js:1:358533
CSP: blocked an inline script (script-src-elem) ... at 681de782-e99b-49c9-b28e-02e6e80251e1:19:51

Those are a browser extension injecting into your page, with the policy refusing. MetaMask does this and so does any extension that injects scripts into the document. You cannot allow them while a hash is present, per the rule above. Blocking them only affects the extension.

Check where a violation comes from before acting on it. A violation at moz-extension://... or a UUID-named document is not your code. The dev panel counts these on a line of their own rather than listing them. Neither are the MaxListenersExceededWarning and orphaned-stream lines that come with it.

Check it in the production shape#

A policy is a header, so check it in the shape that sends it. Run the deploy tree behind whatever will sit in front of it in production and read the console on every route, the way 440 does. A CSP violation is a console error, so the same sweep catches it.

Watch for a page that renders but does not respond, because the module that hydrates it was blocked. The page looks right, so it can look like a CSS or event-handler bug.

Next up: 500. Move a loader into Rust.