Security headers (CSP, HSTS, Permissions-Policy, …)
OrbitalReg ships strict HTTP security headers on every response from the API, the SPA static-asset container, and every operator-hosted nginx vhost (orbitalreg.com / orbitalreg.com / portal.orbitalreg.com / docs.orbitalreg.com). Pen-test reports and procurement reviews routinely ask for Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, Referrer-Policy, and Permissions-Policy; this page documents what we emit, why, and how to customise it.
What we emit
| Header | Default value | Source |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains | API + SPA + nginx snippet |
Content-Security-Policy | strict per-surface variant (see below) | API + SPA + nginx snippet |
X-Frame-Options | DENY | API + SPA + nginx snippet |
X-Content-Type-Options | nosniff | API + SPA + nginx snippet |
Referrer-Policy | strict-origin-when-cross-origin | API + SPA + nginx snippet |
Permissions-Policy | every surface OrbitalReg never asks for is disabled | API + SPA + nginx snippet |
Cross-Origin-Opener-Policy | same-origin | API + SPA + nginx snippet |
Cross-Origin-Embedder-Policy | credentialless | API + SPA + nginx snippet |
Why three CSP variants?
The Content-Security-Policy is tuned per surface:
- API responses (
/api/v1/*,/api/admin/*,/auth/*,/health/*) returndefault-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'. The API never serves a navigation document, so the most restrictive directive is the right default — every loader path blocked, no inline-script footgun. - SPA pages served by the frontend nginx allow
script-src 'self'(Vite emits hashed module scripts under/assets/),style-src 'self' 'unsafe-inline'(Tailwind injects per-element style attributes),img-src 'self' data: https:, andconnect-src 'self'. - Portal (Astro-SSR) relaxes
style-srcto allow hashed inline styles. The strict-defaultsecurity-headers.confis replaced withsecurity-headers-portal.conffor that vhost only.
Why credentialless, not require-corp, for COEP?
Cross-Origin-Embedder-Policy pairs with COOP to give the page cross-origin isolation. require-corp is the stricter of the two valid values, but it blocks any cross-origin subresource that doesn't carry a Cross-Origin-Resource-Policy header — and several surfaces load cross-origin resources we don't control the headers of: the marketing/docs sites' img-src … https: allows arbitrary third-party images, and a customer's OIDC IdP or branding-asset host is never guaranteed to send CORP. credentialless gives the same isolation guarantee while loading those cross-origin requests anyway (stripped of cookies/auth instead of blocked), so it ships without an opt-in audit of every third-party asset first.
Where the headers come from
1. The API server (appmw.SecurityHeaders)
The chi middleware at api/internal/middleware/security_headers.go stamps the headers on every response — including 503 maintenance gates, 403 block hits, and CORS preflight responses. Procurement scanners hit error paths far more often than the happy path; the headers ride on every status code.
Configurable via env vars:
ORBITALREG_HTTP_SECURITY_HEADERS_DISABLE=false # delegate to a sibling reverse proxy
ORBITALREG_HTTP_HSTS_MAX_AGE_SECONDS=63072000 # set 0 to omit HSTS (plaintext local dev)
ORBITALREG_HTTP_HSTS_PRELOAD=false # only flip true after submitting to hstspreload.org
ORBITALREG_HTTP_CONTENT_SECURITY_POLICY="" # override the strict default2. The SPA static-asset container (frontend/nginx.conf)
The bundled nginx config in frontend/Dockerfile ships the same headers with add_header ... always; so they survive 404 / 5xx pages too.
3. The operator-hosted nginx vhosts
Drop deploy/nginx/security-headers.conf (or security-headers-portal.conf for portal) into /etc/nginx/conf.d/ on the IONOS Frankfurt VM and add an include /etc/nginx/conf.d/security-headers.conf; directive to each server { … } stanza in:
/etc/nginx/sites-available/orbitalreg.com/etc/nginx/sites-available/orbitalreg.com/etc/nginx/sites-available/portal.orbitalreg.com← portal-relaxed variant/etc/nginx/sites-available/docs.orbitalreg.com/etc/nginx/sites-available/status.orbitalreg.com
Reload after editing:
sudo nginx -t && sudo systemctl reload nginxVerification
Quick smoke-check:
curl -sI https://orbitalreg.com | grep -Ei 'strict-transport-security|content-security-policy|x-frame-options|referrer-policy|permissions-policy|cross-origin-opener-policy'Online graders:
- https://securityheaders.com/?q=orbitalreg.com — target A+
- https://observatory.mozilla.org/analyze/orbitalreg.com — target B+
Both should return their top tier with the snippet in place.
Customisation knobs
Helm (charts/orbitalreg) — customer K8s installs
The chart's nginx-ingress annotations include a configuration-snippet hook that mirrors the strict header set by default. Customers who run a sibling reverse proxy that already adds these headers (e.g. an upstream WAF / Cloudflare / Akamai) can flip the toggle off:
ingress:
securityHeaders:
enabled: true # default
contentSecurityPolicy: "" # leave empty for the default
hstsMaxAgeSeconds: 63072000
hstsPreload: falseThe API itself emits the same headers via the chi middleware regardless of whether the ingress annotations are wired up — even with the toggle off, the end-user response carries them.
Environment variables (any deployment)
Bare-metal / docker-compose deployments that don't run the Helm chart can override via ORBITALREG_HTTP_* env vars; see the table above.
CSP violation reporting
POST /api/csp-report (api/internal/handlers/csp_report.go) accepts browser-submitted violation reports in either wire shape — the legacy application/csp-report single-object envelope (report-uri) or the newer application/reports+json array envelope (Report-To / Reporting API). Each report is logged as one component=api subcomponent=csp_report structured slog line (document/blocked URI, violated/effective directive, disposition) so the SIEM/Loki view can be queried for real violations. The endpoint is unauthenticated and exempt from the licence gate (see docs/LICENSING-ROADMAP.md §5) so reports keep flowing even in degraded mode; it always acknowledges with 204 so a parse hiccup on our side never surfaces as a console error to the visitor.
The SPA nginx config (frontend/nginx.conf) now ships a second, stricter policy on the Content-Security-Policy-Report-Only header alongside the enforcing Content-Security-Policy: identical except style-src drops 'unsafe-inline', plus a report-uri /api/csp-report directive. Nothing is blocked by this header — it only lets an operator observe, via the CSP violation log above, how many of the SPA's inline style="…" attributes would break if 'unsafe-inline' were removed for real.
That removal turns out not to be reachable via nonces — see the next section for why.
Out of scope for v1
- Subresource Integrity (SRI) for CDN-loaded assets — OrbitalReg hosts every asset itself, so SRI hashes have no externally-mutable target to pin against. Not added.
- Per-route CSP nonces for
<script>/<style>elements — Vite emits a single hashed<script type="module">and no inline<style>blocks, soscript-src 'self'is already strict with no nonce needed. - Dropping
style-src 'unsafe-inline'via nonces — investigated under roadmap item 193 and found technically unreachable, not just unbuilt. CSP nonces only whitelist elements that carry a matchingnonce="…"attribute (style-src-elem/script-src-elem); there is no nonce mechanism for individualstyle="…"attribute values (style-src-attronly accepts'unsafe-inline'or'unsafe-hashes'against a literal, static value). The SPA sets an inlinestyleprop at ~12 call sites for values computed at render time (progress-bar/bar-chart widths, tree-indent depth, a format-icon mask URL, a tab-dependent gradient) — none reduce to a finite, hashable set.@radix-ui/react-dialogand@radix-ui/react-dropdown-menuadditionally set their own inline styles at runtime for Popper positioning, outside our control. Reaching a nonce-cleanstyle-srcwould mean either replacing Radix's positioning or re-architecting every dynamic style onto pre-generated, nonced<style>blocks — out of scope for a bounded item batch; see roadmap item 193 for the resulting scope decision.
What changed (item 82)
api/internal/middleware/security_headers.go— chi middleware, config struct, defaults, six unit tests covering header set / HSTS variants / disable path / handler-side override / custom CSP.api/cmd/server/main.go— wires the middleware afterRecovererandMetricsso degraded responses still carry the headers.api/internal/config/config.go— four newHTTPfields driven byhttp.security_headers_disable/http.hsts_max_age_seconds/http.hsts_preload/http.content_security_policy.frontend/nginx.conf— strict CSP + HSTS + the rest, applied withalwaysso 4xx/5xx pages also carry them.deploy/nginx/security-headers.conf+security-headers-portal.conf— drop-in nginx snippets for the IONOS-hosted marketing / docs / status / portal vhosts.charts/orbitalreg— Helm values + ingress annotations expose theingress.securityHeadersknob.