Skip to content

feat: add Content-Security-Policy and opt-in HSTS, fix healthz routing - #449

Merged
thegdsks merged 1 commit into
mainfrom
feat/csp-hsts-and-healthz-routing
Sep 14, 2026
Merged

thegdsks merged 1 commit into
mainfrom
feat/csp-hsts-and-healthz-routing

Conversation

@thegdsks

Copy link
Copy Markdown
Member

Summary

Ships the two security headers explicitly deferred from the earlier production-readiness batch (#445), plus a real routing bug found while live-verifying them.

  • Content-Security-Policy: sent unconditionally on every response, script-src 'self' with no unsafe-inline/unsafe-eval. Required extracting web/index.html's inline theme-flash-prevention script into web/public/theme-init.js first (verified via a real npm run build that the built dist/index.html has zero inline <script> content left, timing preserved: still a blocking, synchronous, non-module script).
  • Strict-Transport-Security: opt-in via APP_ENABLE_HSTS (default off). This control plane's own HTTP server never terminates TLS itself (embedded Caddy does, proxying to it over loopback), so it has no way to tell from a request alone whether the browser saw a trusted ACME certificate or the self-signed internal-issuer default. HSTS on a self-signed deployment removes the browser's "proceed anyway" escape hatch, turning a cert warning into a hard lockout, so this stays a manual opt-in rather than inferred. Documented in docs/domains-and-ingress.md next to the existing ACME/self-signed TLS explanation.
  • healthz routing fix: while live-verifying the headers, found that the control plane's top-level mux only forwards the /api/ prefix to the API router, so a plain GET /healthz (what an orchestrator or load balancer actually probes) fell through to the SPA catch-all and returned a 200 with the dashboard's index.html body instead of {"status":"ok"}. Extracted the mux composition into a small composeMux function so this routing precedence is directly unit-tested instead of only reachable through rootHandler's full dependency graph.

Verification

  • go build ./..., go vet ./..., golangci-lint run clean.
  • go test ./internal/api/... -short and go test ./cmd/levelrail/... -short pass, including new tests for both the header middleware and the mux routing precedence.
  • Live end-to-end: built with -tags embedweb, ran the real binary against an isolated docker:dind daemon (never the shared host daemon), confirmed GET /healthz, GET /, GET /theme-init.js, and GET /api/v1/brand all return the expected body and full header set, then tore the isolated environment down.
  • npx tsc -b clean on the frontend change.

What this doesn't do

  • No nonce/hash-based CSP: style-src keeps 'unsafe-inline' because @xterm/xterm (the exec terminal) injects its own <style> element at runtime for cursor rendering. Locking that down needs per-request nonce plumbing through the otherwise-static embedded index.html, not worth it for the risk removed (inline style can't execute script).
  • Does not gate HSTS on the ingress settings' ACMEEnabled flag automatically; that would need a DB read on every single request for a header most deployments won't enable yet, so it stays a manual operator opt-in instead.
  • Does not add a CSP report-uri/report-to endpoint to collect violation reports from real browsers.

Ships the two security headers explicitly deferred from the earlier
production-readiness batch (securityHeadersMiddleware's own prior doc
comment):

- Content-Security-Policy is now sent unconditionally, script-src
  locked to 'self' with no unsafe-inline/unsafe-eval. This required
  extracting web/index.html's inline theme-flash-prevention script into
  web/public/theme-init.js first, verified via a real npm run build
  that the built dist/index.html has no inline script left.
- Strict-Transport-Security is opt-in via APP_ENABLE_HSTS (default
  off): this control plane's own HTTP server never terminates TLS
  itself, so it can't tell from a request alone whether the browser saw
  a trusted ACME certificate or the self-signed internal-issuer
  default, and HSTS on a self-signed deployment turns a certificate
  warning into a hard lockout. Documented in domains-and-ingress.md
  next to the ACME/self-signed TLS explanation.

Also fixes a real routing bug found while live-verifying this: the
control plane's top-level mux only forwarded the "/api/" prefix to the
API router, so a plain GET /healthz (what an orchestrator or load
balancer actually probes) fell through to the SPA catch-all and got a
200 with the dashboard's index.html body instead of {"status":"ok"}.
Extracted the mux composition into composeMux so this routing
precedence is directly unit-tested instead of only reachable through
rootHandler's full dependency graph.

Verified live: built with -tags embedweb, ran against an isolated
docker:dind daemon, confirmed GET /healthz, GET /, GET /theme-init.js,
and GET /api/v1/brand all return the expected body and headers.

What this doesn't do:
- No nonce/hash-based CSP: style-src keeps 'unsafe-inline' because
  @xterm/xterm injects its own <style> element at runtime; locking that
  down needs per-request nonce plumbing through the otherwise-static
  embedded index.html, not worth it for the risk removed (inline style
  can't execute script).
- Does not gate HSTS on the ingress settings' ACMEEnabled flag
  automatically; that would need a DB read on every request for a
  header most deployments won't enable yet, so it stays a manual
  operator opt-in instead.
- Does not add a CSP report-uri/report-to endpoint to collect violation
  reports from real browsers.
@thegdsks
thegdsks merged commit b193889 into main Sep 14, 2026
7 checks passed
@github-actions github-actions Bot added size/l 200-499 lines changed type/feature New capability or ergonomic improvement area/frontend web/ area/api internal/api type/docs Documentation only labels Sep 14, 2026
@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

The PR is not yet safe to merge because CSP misses the dashboard document and HSTS can unexpectedly lock browsers out of preview subdomains.

Findings

  1. P1 Security CSP Bypasses Dashboard
  2. P1 HSTS Covers Preview Subdomains

Summary

  • CSP is attached only to the API router and does not reach the dashboard HTML.
  • HSTS silently covers preview and other subdomains despite documentation discussing only the dashboard host.
  • The corrected plain GET /healthz routing is consistent with the intended contract.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Request[Incoming request] --> TopMux[composeMux]
  TopMux -->|/healthz or /api/*| API[Router.Handler]
  API --> Headers[Security headers middleware]
  Headers --> APIRoutes[API routes]
  TopMux -->|POST /webhook| Webhook[Webhook handler]
  TopMux -->|all other paths| Web[SPA/static web.Handler]
  Web -. bypasses CSP/HSTS middleware .-> Browser[Dashboard document and assets]
Loading

Reviews (1) · Last reviewed commit: "feat: add Content-Security-Policy and op..."

Comment thread cmd/levelrail/main.go
}

rt := api.NewRouter(logger, b, db, opts...)
return composeMux(rt.Handler(), webhookHandler, web.Handler()), rt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security CSP Bypasses Dashboard

composeMux sends /healthz and /api/ through rt.Handler(), where the security middleware is installed, but sends / and static assets directly through web.Handler(). As a result, the dashboard HTML does not receive the new Content-Security-Policy, so the policy does not restrict script execution on the page it is meant to protect.

How this was verified: GET / is routed directly to the unwrapped web handler, whereas the CSP middleware exists only inside the API handler.

// Content-Security-Policy, is unconditional.
func securityHeadersMiddleware(hstsEnabled bool) func(http.Handler) http.Handler {
hsts := fmt.Sprintf("max-age=%d; includeSubDomains", int(hstsMaxAge.Seconds()))
return func(next http.Handler) http.Handler {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 HSTS Covers Preview Subdomains

Enabling HSTS also sends includeSubDomains, although the opt-in documentation describes enforcement only for the dashboard host. Preview environments use names such as pr-<number>.<app>.<primary-domain> and may still use Caddy's internal issuer when the dashboard itself has a trusted certificate. After an API response stores this policy, browsers will reject those preview certificates without allowing a bypass for 180 days. Either omit includeSubDomains or require and document trusted TLS for every subdomain before enabling HSTS.

@thegdsks
thegdsks deleted the feat/csp-hsts-and-healthz-routing branch September 14, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api internal/api area/frontend web/ size/l 200-499 lines changed type/docs Documentation only type/feature New capability or ergonomic improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant