Skip to content

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("*", ...).


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-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()

Verify any time with:

Terminal window
curl -sI https://dashboard.celestialintelligence.co/ | \
grep -iE 'content-security|strict-transport|x-content|x-frame|referrer|permissions'

DirectiveValueWhy
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.netSame-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.netSelf-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.comThe 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.

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:

  1. Refactoring every style="..." Lit template binding to either class-based Tailwind utilities or programmatic .style.x = property writes (which don’t trigger CSP).
  2. 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.


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 spec
  • upgrade-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 when
delivered in a report-only policy.
The Content Security Policy directive 'upgrade-insecure-requests' is
ignored 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.


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 it
violates 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.

SymptomFix
New third-party CSS/font origin neededAdd it to style-src (and font-src if it’s a .woff2)
New CDN-hosted JS bundleDon’t. Vendor it instead. script-src 'self' is the line we hold.
Background API call to a new same-account serviceAdd to connect-src. Prefer same-origin proxy when possible.
data: URIs in inline SVGAlready allowed via img-src 'self' data:
WebWorker addedAdd worker-src 'self' (default-src covers it but explicit is clearer)
inline onclick="..." legacy markupDon’t. Use addEventListener. We have a strict script-src 'self' with no 'unsafe-inline' and want to keep it that way.

The middleware runs in dev too (vite dev server proxies through dashboard-server when both are running). To test a CSP change without deploying:

Terminal window
pnpm --filter @celestial/dashboard-server dev # starts on :8788
pnpm --filter @celestial/dashboard dev # starts on :5173, proxies to 8788

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

to 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).


We ship Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, which qualifies for the HSTS preload list.

To submit:

  1. Confirm dashboard.celestialintelligence.co has had this header live for at least a week with no issues.
  2. Confirm every subdomain of celestialintelligence.co redirects http://https:// and serves valid certs. (Required because of includeSubDomains.)
  3. 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.


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 down api., docs., etc.
  • preload — marks us as eligible for the browser-baked-in preload list.
  • Disables browser MIME-type sniffing. A JSON response can’t be reinterpreted as HTML or JS regardless of its content.
  • Legacy header (pre-frame-ancestors). Some older browsers / scanners expect it. DENY matches our frame-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-cohort because it’s been removed from Chrome.