Dashboard security headers
The hosted dashboard (dashboard.celestialintelligence.co,
ADR-021)
ships a strict Content-Security-Policy and the usual hardening header
suite on every response. This page documents what’s set, why, how to
debug violations, and how to extend the policy when new third-party
origins land.
Source of truth: the middleware lives at the top of
packages/dashboard-server/src/server.ts, immediately after the Hono
app is instantiated. It runs on every route via app.use("*", ...).
What’s set
Section titled “What’s set”Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; form-action 'self' https://api.workos.com; base-uri 'self'; object-src 'none'; upgrade-insecure-requestsStrict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), microphone=(), camera=(), payment=()Verify any time with:
curl -sI https://dashboard.celestialintelligence.co/ | \ grep -iE 'content-security|strict-transport|x-content|x-frame|referrer|permissions'CSP directives — what each one allows
Section titled “CSP directives — what each one allows”| Directive | Value | Why |
|---|---|---|
default-src | 'self' | Anything not otherwise listed defaults to same-origin. Belt-and-suspenders catch-all. |
script-src | 'self' | Only /assets/index-*.js from Vite. No inline scripts, no eval, no third-party. If an attacker injects <script> into a page, it won’t execute. |
style-src | 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net | Same-origin CSS + Google Fonts CSS + jsDelivr (bootstrap-icons CSS). 'unsafe-inline' is allowed deliberately — see “Why 'unsafe-inline' in style-src” below. |
font-src | 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net | Self-hosted + Google Fonts woff2 + bootstrap-icons woff2 from jsDelivr. |
img-src | 'self' data: | Same-origin images + data: URIs (used in shadow-DOM CSS for scrollbar/icon tweaks). |
connect-src | 'self' | Same-origin fetch() + EventSource() only. Blocks any sneaky exfil to attacker hosts. SSE on /api/state/events works because it’s same-origin. |
frame-ancestors | 'none' | Anti-clickjacking — no one may embed the dashboard in an iframe. |
form-action | 'self' https://api.workos.com | The dashboard has no HTML <form>s today, but WorkOS is allowlisted for the eventual AuthKit POST round-trip. |
base-uri | 'self' | Prevents <base href="..."> injection from rewriting all relative URLs. |
object-src | 'none' | No <object>, <embed>, Flash, etc. |
upgrade-insecure-requests | (no value) | Auto-promotes any accidental http:// to https://. Kept in the ENFORCING policy (not report-only) where the directive is valid. |
Why 'unsafe-inline' in style-src
Section titled “Why 'unsafe-inline' in style-src”Lit’s static styles = css\…`blocks land inCSSStyleSheets and are adopted via adoptedStyleSheetsat the shadow-root level — **those do not need’unsafe-inline’`**.
But several of our Lit templates do this:
return html`<div style="width:${pct}%; background:${color}">…</div>`;Lit compiles style="..." template bindings to
setAttribute("style", ...) on the host element, which browsers
classify as inline style and gate behind
style-src 'unsafe-inline' (or a per-render style nonce, which we’d
need server-rendered HTML to issue).
'unsafe-inline' for STYLE is dramatically lower-risk than for SCRIPT:
- Inline styles can’t execute code.
- The XSS impact is limited to CSS-injection tricks (UI confusion, Content-Spoofing) — bad but not RCE.
script-src 'self'(no'unsafe-inline') still blocks all of the high-severity XSS payloads.
Removing 'unsafe-inline' from style-src would require:
- Refactoring every
style="..."Lit template binding to either class-based Tailwind utilities or programmatic.style.x =property writes (which don’t trigger CSP). - OR introducing CSS nonces, which means SSR’ing the index.html with a per-request nonce — a much bigger architectural change.
It’s a known concession; revisit if (a) we move to SSR for other reasons, or (b) a CSP audit downgrades us for it.
The WorkOS AuthKit noise (not us)
Section titled “The WorkOS AuthKit noise (not us)”When users hit /auth/login they’re redirected through WorkOS AuthKit
pages (*.authkit.app). WorkOS’s pages ship their own
Content-Security-Policy-Report-Only header containing two directives
that are invalid in report-only mode:
frame-ancestors— only honored in enforcing mode per specupgrade-insecure-requests— same
This produces these console warnings during login on the AuthKit origin, not ours:
The Content Security Policy directive 'frame-ancestors' is ignored whendelivered in a report-only policy.The Content Security Policy directive 'upgrade-insecure-requests' isignored when delivered in a report-only policy.This is a WorkOS-side quirk, harmless, and not actionable on our end.
File with WorkOS support if you ever care to chase it. Confirm it’s not
us by checking the enforcing CSP on dashboard.celestialintelligence.co/
— we don’t ship a report-only policy at all.
Debugging CSP violations
Section titled “Debugging CSP violations”If something on the dashboard stops working after a change, always check the browser console first. CSP violations are loud:
Refused to load the stylesheet 'https://example.com/foo.css' because itviolates the following Content Security Policy directive: "style-src 'self''unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net".The directive name + blocked URL tells you exactly which allowlist needs extending.
Common fixes
Section titled “Common fixes”| Symptom | Fix |
|---|---|
| New third-party CSS/font origin needed | Add it to style-src (and font-src if it’s a .woff2) |
| New CDN-hosted JS bundle | Don’t. Vendor it instead. script-src 'self' is the line we hold. |
| Background API call to a new same-account service | Add to connect-src. Prefer same-origin proxy when possible. |
data: URIs in inline SVG | Already allowed via img-src 'self' data: |
| WebWorker added | Add worker-src 'self' (default-src covers it but explicit is clearer) |
inline onclick="..." legacy markup | Don’t. Use addEventListener. We have a strict script-src 'self' with no 'unsafe-inline' and want to keep it that way. |
Reproducing locally
Section titled “Reproducing locally”The middleware runs in dev too (vite dev server proxies through dashboard-server when both are running). To test a CSP change without deploying:
pnpm --filter @celestial/dashboard-server dev # starts on :8788pnpm --filter @celestial/dashboard dev # starts on :5173, proxies to 8788Open Chrome devtools → Network → click the document → Headers tab. The CSP shows up under “Response Headers”.
Adding a CSP report endpoint (optional, future)
Section titled “Adding a CSP report endpoint (optional, future)”Right now we don’t capture violations server-side. To do so, add:
report-to csp-defaultto the policy string, plus:
c.header("Reporting-Endpoints", `csp-default="${cfg.baseUrl}/api/csp-report"`);Then implement POST /api/csp-report to log JSON violation reports to
Datadog / your sink of choice. Reports are sent as
application/csp-report JSON; spec at
https://www.w3.org/TR/CSP3/#violation-events.
This would let us observe how often we’re violating in the wild without shipping users a broken experience (rare with current policy — we’re not on the bleeding edge).
HSTS preload
Section titled “HSTS preload”We ship Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, which qualifies for the HSTS preload
list.
To submit:
- Confirm
dashboard.celestialintelligence.cohas had this header live for at least a week with no issues. - Confirm every subdomain of
celestialintelligence.coredirectshttp://→https://and serves valid certs. (Required because ofincludeSubDomains.) - Submit at https://hstspreload.org.
Don’t submit prematurely — the preload entry is sticky, and undoing it takes browser-vendor cooperation. Marketing site, docs, and api are all already HTTPS-only behind Caddy, so this is plausible — verify each explicitly before submitting.
Per-header rationale (the non-CSP ones)
Section titled “Per-header rationale (the non-CSP ones)”Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Section titled “Strict-Transport-Security: max-age=63072000; includeSubDomains; preload”- 2-year max-age. Browsers refuse
http://for the whole window. includeSubDomains— also locks downapi.,docs., etc.preload— marks us as eligible for the browser-baked-in preload list.
X-Content-Type-Options: nosniff
Section titled “X-Content-Type-Options: nosniff”- Disables browser MIME-type sniffing. A JSON response can’t be reinterpreted as HTML or JS regardless of its content.
X-Frame-Options: DENY
Section titled “X-Frame-Options: DENY”- Legacy header (pre-
frame-ancestors). Some older browsers / scanners expect it.DENYmatches ourframe-ancestors 'none'.
Referrer-Policy: strict-origin-when-cross-origin
Section titled “Referrer-Policy: strict-origin-when-cross-origin”- When users click an external link, only the origin (not the full pathname or query) leaks in the Referer header.
- Same-origin requests still get the full URL — needed for ourselves.
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()
Section titled “Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()”- Denies the four sensitive permission APIs we don’t use. If we ever need one, remove that token from the deny list.
- We don’t include FLoC /
interest-cohortbecause it’s been removed from Chrome.
See also
Section titled “See also”packages/dashboard-server/src/server.ts— the middleware itself- ADR-021 — saas-dashboard-workos — auth + hosting architecture
- ADR-033 — platform-services-as-remote-mcp — Track A session model this CSP protects