Skip to content

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

HeaderDefault valueSource
Strict-Transport-Securitymax-age=63072000; includeSubDomainsAPI + SPA + nginx snippet
Content-Security-Policystrict per-surface variant (see below)API + SPA + nginx snippet
X-Frame-OptionsDENYAPI + SPA + nginx snippet
X-Content-Type-OptionsnosniffAPI + SPA + nginx snippet
Referrer-Policystrict-origin-when-cross-originAPI + SPA + nginx snippet
Permissions-Policyevery surface OrbitalReg never asks for is disabledAPI + SPA + nginx snippet
Cross-Origin-Opener-Policysame-originAPI + SPA + nginx snippet
Cross-Origin-Embedder-PolicycredentiallessAPI + SPA + nginx snippet

Why three CSP variants?

The Content-Security-Policy is tuned per surface:

  • API responses (/api/v1/*, /api/admin/*, /auth/*, /health/*) return default-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:, and connect-src 'self'.
  • Portal (Astro-SSR) relaxes style-src to allow hashed inline styles. The strict-default security-headers.conf is replaced with security-headers-portal.conf for 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:

bash
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 default

2. 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:

bash
sudo nginx -t && sudo systemctl reload nginx

Verification

Quick smoke-check:

bash
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:

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:

yaml
ingress:
  securityHeaders:
    enabled: true                          # default
    contentSecurityPolicy: ""              # leave empty for the default
    hstsMaxAgeSeconds: 63072000
    hstsPreload: false

The 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, so script-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 matching nonce="…" attribute (style-src-elem/script-src-elem); there is no nonce mechanism for individual style="…" attribute values (style-src-attr only accepts 'unsafe-inline' or 'unsafe-hashes' against a literal, static value). The SPA sets an inline style prop 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-dialog and @radix-ui/react-dropdown-menu additionally set their own inline styles at runtime for Popper positioning, outside our control. Reaching a nonce-clean style-src would 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 after Recoverer and Metrics so degraded responses still carry the headers.
  • api/internal/config/config.go — four new HTTP fields driven by http.security_headers_disable / http.hsts_max_age_seconds / http.hsts_preload / http.content_security_policy.
  • frontend/nginx.conf — strict CSP + HSTS + the rest, applied with always so 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 the ingress.securityHeaders knob.

Released under the Apache-2.0 License.