brust 2.0.0-alpha.50
Guide / Content Security Policy

Content Security Policy

With [csp] on, brust sends a strict Content-Security-Policy header with every HTML page. Scripts may come only from your own origin, plus the few small inline scripts brust writes itself, which the policy lists by their hash.

Turning it on

brust.toml
# brust.toml
[csp]
enabled = true

It is off by default: without enabled = true no header is sent and nothing changes. The table is checked either way, so a mistake in it stops brust start with a csp: … error. A key brust does not know is an error too, so a misspelt directive cannot pass unnoticed.

The policy brust sends

response header
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-…'; style-src-elem 'self';
  style-src-attr 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'
  • script-src allows your own origin and one sha256 hash for each inline script brust writes: the base path global (with [server] base_path) and the actions client globals (a non-default [actions] prefix, a base_url or credentials). These scripts are the same for every page of a running server, so brust computes their hashes once at startup. Everything else brust adds to a page is a src script from your origin, or a JSON data block, which the policy does not govern.
  • style-src-elem 'self' allows stylesheets from your origin (your CSS, Tailwind and CSS modules). The navigation progress bar builds its styles through the CSSOM, which this does not block. Its rules have no specificity, so any rule of yours on div[data-brust-bar] overrides them.
  • style-src-attr 'unsafe-inline' is the one relaxation brust always sends. The compiler prints style="…" attributes and x-bind-style sets them at runtime; their values change with your data, so they cannot be hashed. A style attribute cannot run script.
  • style-src 'self' 'unsafe-inline' is a fallback for browsers that do not know the two directives above (Safari before 15.4). Browsers that know them ignore style-src, so for them the policy is the stricter one. Your style_src sources are added to it too. It covers styles only; scripts stay strict everywhere.
  • connect-src is sent only when it has more than your own origin: the actions base_url when it is another origin, and your connect_src list. Otherwise default-src 'self' covers it.

Adding sources

brust.toml
# brust.toml (every key optional)
[csp]
enabled = true
mode = "enforce"                         # or "report-only"
script_src = ["https://cdn.example.com"]
style_src = ["https://fonts.googleapis.com"]
connect_src = ["wss://live.example.com"]
img_src = ["data:", "https://images.example.com"]
font_src = ["https://fonts.gstatic.com"]
frame_ancestors = ["'none'"]
report_uri = "/csp-report"
form_action = ["https://pay.example.com"]
frame_src = ["https://www.youtube-nocookie.com"]
media_src = ["https://media.example.com"]
worker_src = ["blob:"]
manifest_src = []
object_src = []                          # empty = 'none'
default_src = "self"                     # or "none"
base_uri = "self"                        # or "none"

Each list adds sources to its directive, after 'self'. style_src goes into style-src-elem. img_src, font_src, frame_src, media_src, worker_src, manifest_src and frame_ancestors are sent only when you set them. Without frame_ancestors, framing is not restricted, as it is without [csp].

form-action does not fall back to default-src, so brust always sends form-action 'self', plus your form_action list: a form can post to your own app (actions included) and to the origins you name. object_src replaces 'none' when you set it. base_uri is "self" or "none".

One entry is one source: a host (https://cdn.example.com), a scheme (data:) or a quoted keyword ('self', 'none', 'unsafe-inline', 'nonce-…', 'sha256-…'). An entry with ;, ,, a line break, a non-ASCII character or an unbalanced quote is refused, so no entry can end the directive and start another.

default-src 'none'

brust.toml
# brust.toml: nothing is allowed unless named
[csp]
enabled = true
default_src = "none"

With default_src = "none" nothing falls back to your own origin. brust then names what it loads itself: script-src and style-src-elem as above, connect-src 'self' (page fetches of navigation and prefetch, actions, your WebSockets), img-src 'self' (images a navigation preloads, and those brust-built CSS serves) and font-src 'self' (fonts brust-built CSS serves). It loads no frames, media, workers or manifests, so those stay closed until you list them; then only what you list is allowed (no 'self' is added).

The request host

brust.toml
# brust.toml: one app serving many host names, each page's socket on its own host
[csp]
enabled = true
connect_src = ["wss://{host}"]

A source may hold {host}. brust replaces it with the host the request was sent to (with its port), as each response goes out, so one app serving many host names allows each page its own host. The Host header is written by the client, so it is used only when it is a plain host name or IP address with an optional port. Anything else, and every source that holds {host} is left out of that response’s policy: it is never copied into the header. {host} is refused in script_src (a script source taken from the request would let any host name that reaches your server serve script) and in object_src (plugins stay 'none' or named hosts). Static pages and the Cloudflare public/ rule have no request, so the build leaves those sources out and prints a warning. A response whose policy has a {host} source carries Vary: Host, so a cache in front of brust keeps one copy per host.

Inline styles

An app that writes <style> elements inline (for example with dangerouslySetInnerHTML) adds 'unsafe-inline' to style_src. Scripts stay strict:

brust.toml
# brust.toml: an app that writes <style> inline (for example with dangerouslySetInnerHTML)
[csp]
enabled = true
style_src = ["'unsafe-inline'"]

Inline scripts

Under mode = "enforce", a <script> you write inline is blocked: only brust’s own inline scripts are listed. Move the code into a file and load it with src, or run in report-only mode. brust does not add nonces for your inline scripts yet.

script_src takes hosts (https://cdn.example.com, *.example.com), and refuses the sources that would let any script run, unless you also set script_src_unsafe = true: 'unsafe-inline' (next to brust’s hashes browsers ignore it anyway), 'unsafe-eval', 'unsafe-hashes', a host of just * (*, https://*, *:443), and a bare scheme such as https:, data:, blob: or filesystem:. 'wasm-unsafe-eval' is allowed. The other lists take all of these freely.

'strict-dynamic' is refused in script_src always, even with script_src_unsafe: under it browsers ignore 'self' and host sources, so every <script src> brust adds (the runtime, islands, navigation) would be blocked. It works only with nonces, which brust does not add yet.

Report-only

With mode = "report-only" the same policy goes out as Content-Security-Policy-Report-Only: the browser blocks nothing, and reports each violation in the console (and to report_uri when you set one). Start there on an existing app, read what it reports, then switch to "enforce".

Your own header

A loader that sets its own Content-Security-Policy or Content-Security-Policy-Report-Only header (in res.headers) keeps it, and brust adds neither of its own to that response. The header is not part of the cached page, so a page served from the cache gets the same header as a fresh render.

Switching it per deploy

The environment variable BRUST_CSP_MODE, read when the server starts, overrides [csp] without editing brust.toml: off sends no policy; report-only or enforce sends it in that mode, from your [csp] table even when enabled is not true (or the default policy when there is no table). Any other value stops the server at startup. On Cloudflare it is a variable of the Worker (vars or a dashboard variable), read when the Worker starts. It is the switch to use when a policy breaks a page in production.

It reaches only the pages a server or a Worker renders. On --target cloudflare, HTML files in public/ are served by the platform with the policy the build wrote into _headers, and on --target static every page carries the policy fixed at build. Switching those needs a rebuild; the Cloudflare build prints warning cf-csp-public when public/ holds HTML.

Under brust dev

brust.toml
# brust.toml
[csp]
enabled = true
dev = true                 # send it under brust dev too
dev_mode = "enforce"       # default "report-only" under brust dev

brust dev sends no policy unless dev = true. Then it sends yours in dev_mode (report-only by default), with one addition its overlay needs: style-src-elem 'unsafe-inline' for the error overlay’s styles. The dev client, the devtools and their WebSocket are same-origin, which 'self' already allows. brust start never gets the addition.

Where it applies

  • Every HTML response of brust start, and the pages a Cloudflare Worker renders (--target cloudflare). Assets, actions and other non-HTML responses get no header.
  • On --target cloudflare, HTML files you put in public/ are served by the platform, not the Worker. When there are any, the build adds the same policy to the /* rule of dist/cf/assets/_headers.
  • --target static: see Static sites.
  • brust dev sends none unless dev = true: see Under brust dev.

Static sites

A brust build --target static page has no server to send a header, so the policy goes into the page: every page, the 404 page included, opens its <head> with <meta http-equiv="Content-Security-Policy" content="…">, the same policy brust start sends, with the same hashes. It is the first element of the head (right after <meta charset> when the head starts with one, which must stay within the first 1024 bytes) because a policy in a meta tag applies only to what comes after it. Every page carries the same policy, so navigating between pages without a reload stays inside it.

  • Browsers ignore frame-ancestors, report-uri, report-to and sandbox in a meta tag. When your policy has them, the build puts them in the /* rule of dist/static/_headers as a Content-Security-Policy header, which Cloudflare Pages and Workers Static Assets send for every path. On another host, send that header from its own configuration.
  • A report sent through that header covers only the directives in it. Violations of the meta policy are not reported anywhere: a meta tag cannot name a report URI.
  • mode = "report-only" cannot be a meta tag. In that mode the pages get no meta, and the whole policy goes into the /* rule as Content-Security-Policy-Report-Only.
  • A page without a <head> stops the build: it would have no policy.