The headers every answer of the API, the SPA and the MPA sends, what each page may load, and how to allow a new source.
The three apps send the header set of securityHeaders in
@spy4x/server/http/security-headers with every response,
error answers included.
| Header | What it does |
|---|---|
Content-Security-Policy |
Names everything a page may load or run; the tables below list it. |
X-Content-Type-Options: nosniff |
The browser never guesses a file's type, so an upload cannot run as script. |
Referrer-Policy: no-referrer |
No address leaves in a Referer; some carry a reset code or a token. |
X-Frame-Options: DENY, and frame-ancestors 'none' in the policy |
No site can show the app in a frame to trick a click. |
Strict-Transport-Security: max-age=15552000; includeSubDomains |
For 180 days the browser reaches the domain and its subdomains over HTTPS only. |
Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy: same-origin |
Another site can neither hold a handle on the app's window nor embed its files. |
Origin-Agent-Cluster, X-DNS-Prefetch-Control, X-Download-Options, X-Permitted-Cross-Domain-Policies, X-XSS-Protection: 0 |
The remaining defaults of Hono's secureHeaders, which the helper wraps. |
Strict-Transport-Security covers subdomains, so every host under the product's domain must be
served over HTTPS. The production Compose file does that, the MPA's MPA_DOMAIN included. Grafana
has no such host (Admin consoles). Browsers ignore the header
over plain HTTP, so development is not affected.
| Directive | Allows | Why |
|---|---|---|
default-src |
'self' |
Anything not named below, such as the web manifest and the fonts in /fonts/, comes from the app's own origin. |
script-src |
'self' and one 'sha256-…' |
The built files, the service worker /sw.js, and the inline script that paints the stored theme before the first frame. |
style-src |
'self' and one 'sha256-…' |
The built stylesheet and the inline <style> of index.html. |
img-src |
'self', data:, https://lh3.googleusercontent.com, https://avatars.githubusercontent.com |
The icons, the two-factor QR code as a data: address, and the provider's picture on "Create a new account?". |
connect-src |
'self', and the origin of SPA_ERROR_REPORT_DSN when it is set |
The API and its WebSocket, /config.json, and the error tracker. |
object-src |
'none' |
No plugins. |
base-uri |
'self' |
An injected <base> cannot move the app's relative addresses to another site. |
form-action |
'self' |
A form posts to the app only. |
frame-ancestors |
'none' |
No framing. |
There is no 'unsafe-inline' and no 'unsafe-eval'. The service worker and the web manifest need
no directive of their own: worker-src falls back to script-src, and manifest-src to
default-src.
The API passes on a provider's picture from the two img-src hosts only (PROVIDER_AVATAR_ORIGINS
in libs/domain/identity). Any other is dropped, and the page shows initials.
Prefer serving the file from the app's own origin, which needs no change. Otherwise:
- Add the source to
EXTRA_SOURCESinapps/spa/vite.config.ts. - Add it to the directives that
e2e/spa/security-headers.spa.tsexpects. - Add it to the table above.
nginx serves the SPA, so the library's middleware cannot run there. Two files carry the headers into
apps/spa/nginx.conf:
- At build: a plugin in
apps/spa/vite.config.tswritesapps/spa/security-headers.conffrom the builtindex.html, oneadd_header … always;line per header. The hashes therefore always match that build's inline blocks. The file is not committed. - At container start:
apps/spa/csp-connect.shsets the error tracker's origin fromSPA_ERROR_REPORT_DSN, the only part of the policy that differs between environments. It accepts anhttporhttpsaddress of letters, digits and.:/@_-only. Anything else stops the container, naming the variable but not its value.
nginx drops every inherited add_header in a block that has one of its own. So a new location
with an add_header must include the header file again; e2e/spa/security-headers.spa.ts checks
the ones that exist.
nginx serves no byte ranges (max_ranges 0), because its 416 answer would carry every header
twice. A product that serves video or large downloads from the SPA's origin needs ranges back, and
then a location for those files that sets the headers itself.
The Vite dev server sends none of these headers: it injects inline code that no fixed policy fits. The policy is checked behind nginx.
arktype, the validation library, tries new Function once per page load to learn whether it may
compile its validators. The policy refuses it, the browser records the refusal, and arktype
validates without compiling. The app works. The browser walk in e2e/spa fails on any other
refused eval.
The API answers JSON and no page, so it sends the library's default policy with nothing added, the
same as the MPA's below. createApiApp in apps/api/app.ts mounts the headers before every route,
so a refusal, a 404 and a 500 carry them too. A WebSocket upgrade passes through untouched.
| Directive | Allows | When |
|---|---|---|
default-src, script-src, style-src, connect-src |
'self' |
Always. |
img-src |
'self', data: |
Always. |
img-src |
also https: |
On a blog post's page, /blog/<slug> in any language. |
script-src, connect-src |
also the Umami origin | With MPA_UMAMI_ORIGIN set (Analytics (on GitHub)). |
form-action |
'self' |
Always. |
form-action |
also the SPA's origin | When MPA_DOMAIN differs from DOMAIN. |
form-action |
also the demo's origin | When DEMO_ORIGIN is set (demo (on GitHub)). |
object-src |
'none' |
Always. |
base-uri |
'self' |
Always. |
frame-ancestors |
'none' |
Always. |
A post's cover and the images in its body are https: addresses wherever the operator keeps them
(Blog (on GitHub)). Only operators write posts, and an image runs no code. Every other page, the
blog's index included, keeps img-src 'self' data:.
No page has an inline script: the script that wakes a page's islands is a file from the site's own
origin, so 'self' covers it. The pricing page's form posts to the MPA, which redirects to the
app's /try (or the demo's, with DEMO_ORIGIN); a browser applies form-action to that redirect too.
securityHeadersMiddleware in apps/mpa/middleware.ts sets the headers. It runs before the static
files, and also answers errors no route caught, which the website's server would otherwise answer
without the headers.
| What | Where | Runs in |
|---|---|---|
| The API's headers on an answer, a refusal, a 404 and a 500 | apps/api/app.test.ts |
deno task check |
| The running API's headers on an answer and a 404 | e2e/spa/security-headers.spa.ts |
e2e/spa/run.sh (CI step browser) |
| The MPA's headers on a page, a rendered page's nonce, a 404, a 500 and a refusal | apps/mpa/app.test.ts |
deno task check |
| The MPA's headers on real responses; a walk with no refusal under the policy | e2e/mpa/security-headers.mpa.ts |
e2e/mpa/run.sh (CI step browser) |
| The tracker origin the container writes, and what it refuses | tests/spa-runtime-config.test.ts |
deno task check |
| nginx's real responses and the policy's sources; a signed-in walk with no refusal | e2e/spa/security-headers.spa.ts |
e2e/spa/run.sh (CI step browser) |
e2e/spa/run.sh has two modes:
- On a machine (the default): it builds the image of
apps/spa/dockerfile.prod, runs it beside the API, and removes the image afterwards. - In CI (
SPA_SERVER=nginx): CI steps have no container tool, so it builds the SPA and installs it into the step's own nginx at the image's paths. This mode does not prove the Dockerfile'sCOPYlines; the release pipeline's image build stops if the header file is missing.
- Settings:
SPA_ERROR_REPORT_DSN,MPA_UMAMI_ORIGINand the domains. - Analytics (on GitHub)