Content-Security-Policy and the browser-hardening headers¶
The web edge is the only entity a browser reaches, so it sends the browser hardening
headers, on every page it serves. This page describes what the edge sends, how it computes
the Content-Security-Policy (it never sends your configured string as is), and how to
widen it safely. It follows the edge's stampResponse and computeCsp, so it matches what
goes on the wire. Security covers the security model around these headers,
and project layout and config the config keys.
What the edge sends¶
On every served page, stampResponse appends:
| Header | Value | When |
|---|---|---|
Content-Security-Policy |
the computed policy (below) | always |
Cross-Origin-Opener-Policy |
same-origin |
cross_origin_isolation: true |
Cross-Origin-Embedder-Policy |
require-corp |
cross_origin_isolation: true |
Strict-Transport-Security |
max-age=63072000 |
serving over TLS |
X-Content-Type-Options |
nosniff |
always |
Referrer-Policy |
same-origin |
always |
Set-Cookie |
the (anonymous) session cookie | on the client route only |
The Set-Cookie on the client route gives the browser a credential to present at the wss
upgrade before any login. Its SameSite and Secure flags follow origin_model (see
same-origin versus split-origin).
The default policy¶
security.csp defaults to a strict policy with no 'unsafe-inline' and no 'unsafe-eval'
in script-src:
default-src 'self'; connect-src 'self'; img-src 'self' data:;
style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
script-src 'self' 'wasm-unsafe-eval'. The WebAssembly client needs'wasm-unsafe-eval'to instantiate its module, but not'unsafe-eval': SynQt's generated client shell readswindow.locationthrough Embind and builds with-sDYNAMIC_EXECUTION=0, so the Emscripten runtime never callseval(). Keep'unsafe-eval'out.style-srcallows'unsafe-inline', because Qt Quick's WebAssembly canvas sets inline styles. This is the only relaxation, and it covers styles only.object-src 'none',base-uri 'none',frame-ancestors 'none'block plugin embedding,<base>hijacking and clickjacking.
How the edge computes the policy¶
The edge rewrites security.csp before sending it. computeCsp walks your directives and
adjusts three of them, so the policy stays correct as the deployment's origin, threading
and bundle change, without you editing the string:
-
connect-srcgets the sync endpoint's explicitwss://origin. Some browsers do not treat'self'as covering the WebSocket scheme, so a bareconnect-src 'self'would block the live data path. The edge always appends its ownwss://host:port(orws://in plaintext development). If you omitconnect-src, the edge addsconnect-src 'self' <wss-origin>. Do not hardcode the wss origin or port; the edge knows its bound port. -
worker-src 'self' blob:is added under cross origin isolation. Whencross_origin_isolationis on (whichbuild.client_threads: multiimplies), the edge adds this directive unless you wrote one, so the threaded client can start its pthread workers.blob:is a margin for a future toolchain; the current one does not need it. On the toolchain it was measured with (Qt 6.11.1 and Emscripten 4.0.7, before the pin moved to 6.12.0), the loader spawns its workers from the same originclient.js, not fromblob:URLs. The multi threaded proof serves its threaded bundle under the strict policy,worker-src 'self'with noblob:, on every run and in every engine it can launch, and reports each engine's policy violations by directive. All three supported engines have been measured, and none needsblob:: Chromium and Firefox since 2026-07-15, and WebKit, the last, on 2026-07-31 on macOS 15.7.8 (WebKit 26.5). Each reached cross origin isolation, gotSharedArrayBuffer, started its whole pthread pool and logged no CSP violation. A future toolchain that needsblob:will show up as a failure in that run, not as a mystery in production. The allowance stays because Emscripten's way of spawning workers has changed across versions, and removing it gains almost nothing: creating ablob:worker already requires running script, whichscript-srcgoverns. If you set your ownworker-src,'self'alone is enough for this Qt and this emsdk in every engine SynQt targets. -
script-srcgets the sha256 of each inline loader script. The Qt WebAssembly loader in the bundle has an inline bootstrap. Instead of weakening the policy to'unsafe-inline', the edge hashes each inline<script>in the servedindex.htmland adds'sha256-...'toscript-src, so the loader runs under the strict policy. (SynQt's generated shell has no inline handlers; this covers a bundle that does.)
Everything else in your security.csp passes through unchanged.
Cross-origin isolation¶
cross_origin_isolation controls three things together, and the edge keeps them
consistent: COOP same-origin and COEP require-corp (the two headers that isolate the
page so SharedArrayBuffer is available), plus the worker-src 'self' blob: CSP entry
above. Set it directly, or set build.client_threads: multi, which implies it. The multi
threaded client cannot get SharedArrayBuffer without it, so the build turns it on for a
threaded client, and synqt check warns when the configuration said false. In this
mode, every subresource must be same origin or carry Cross-Origin-Resource-Policy or
CORS headers, a consequence of COEP require-corp.
Same-origin versus split-origin¶
origin_model changes the anti-hijacking surface, and the CSP and cookie follow it:
- Same origin (no
origin_modeldeclared): the client is served from the edge origin and connects back to it.allowed_originsis[self](the edge origin),connect-src 'self'plus the appended wss origin is enough, and the session cookie isSameSite=Lax(andSecureunder TLS). split_origin(deprecated, written by hand, never scaffolded): the client is served from a different origin than the sync endpoint, such as a CDN.allowed_originsmust list the client origin, and the session cookie isSameSite=None; Secure, a third party cookie, which browsers are phasing out. Read serving the client from another origin before choosing it.
In both models, the origin check at the wss upgrade, not the CSP, defends against cross
site WebSocket hijacking. The CSP's connect-src still names the sync origin.
Widening the policy safely¶
When an app really needs a third party origin (a font host, an image CDN, an external API
the client calls directly), add the origin to the specific directive in security.csp,
never to default-src, and never as a wildcard:
- Put each origin in its directive: fonts in
font-src, images inimg-src, a directly called API inconnect-src(the edge merges its wss origin into it instead of replacing it). - Never add
'unsafe-eval'or'unsafe-inline'toscript-src. If a bundle needs an inline script, let the edge hash it (serve it inline inindex.html). If it needs eval, reconsider; the SynQt client does not. - Keep
object-src 'none',base-uri 'none'andframe-ancestors 'none'. - Leave out the wss origin. The edge appends it.
synqt check refuses inconsistent origin settings (an edge whose client comes from
another origin while allowed_origins names nothing but self) and warns when it
overrides the isolation a threaded client needs, and synqt doctor lists the resulting
header obligations. The web edge test suite checks the computed policy (the appended wss
origin always, plus COOP, COEP and worker-src 'self' blob: under cross origin
isolation), so a change that breaks the computation fails the build.
Remote pages need no CSP change¶
A remote page is QML the web edge delivers at run time, but it needs no
CSP change. It arrives over the wss link as data, and the QML engine inside the
WebAssembly module parses it. It never reaches the browser's JavaScript engine, never
becomes a <script>, and never calls eval(). script-src governs only what the
browser's JavaScript engine runs, which never sees a remote page, so 'unsafe-inline' and
'unsafe-eval' stay out and the strict policy is the same with or without remote pages.
The client bounds a delivered page with the
palette, the set of QML
modules it may import, not with the CSP.