audit-headers
Audit security headers in a pracht app. The framework sets four defaults on every response path; this covers the exceptions — static output served outside first-party adapters, `headers()` exports tha
SKILL.mdskills/audit-headers/SKILL.md
GitHubPracht Audit Headers
What the framework guarantees: four default security headers are applied
automatically on EVERY framework response path — SSR pages, API responses,
404s, and static/ISG output on the first-party adapters (adapter-node,
adapter-cloudflare) as well as the Vercel headers config generated at build
time:
permissions-policy(disables device sensors)referrer-policy: strict-origin-when-cross-originx-content-type-options: nosniffx-frame-options: SAMEORIGIN
The helper behind this (applyDefaultSecurityHeaders) only sets a header
when missing — so a route or shell headers() export always wins,
including when it weakens a default. The framework does NOT set
strict-transport-security, content-security-policy, or cross-origin-*
headers — those need a project decision.
One deliberate exception: protocol-switch responses (a 101 WebSocket
handshake, or any response carrying a webSocket handle) are returned exactly
as the handler produced them, with no headers applied. This is not a gap —
copying the response would destroy the socket, and a handshake has no body for
a sniffing or framing policy to protect. Do not report it as a finding.
The audit surface is therefore:
- (a)
dist/clientserved by a custom CDN/host outside the first-party adapters — nothing applies the defaults there. - (b)
headers()exports that override a default with a weaker value. - (c) HSTS and CSP, which genuinely need user action.
Prerequisites: pracht inspect requires a vite config that registers the
pracht plugin; pracht inspect build and
dist/server/headers-manifest.json require a prior pracht build. Every
serverful target currently publishes a client copy at
dist/client/_pracht/headers.json; only Cloudflare reads it there. Pure static
exports deliberately do not publish that copy.
Step 1: Inventory header sources
pracht inspect routes --json
pracht inspect api --json
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
Only route modules and shells have a headers() export — API handlers do not
(there is no headers in the API module shape; API handlers set headers
inline on the Response they build, and the runtime applies the defaults on
top). Inventory:
headers()exports in route files, loader files (loaderFile), and shells (shellFile).- Hand-rolled
Responseconstructions in API handlers and middleware where security-relevant headers are set inline.
Step 2: Find weakened defaults and uncovered static hosting
Defaults are framework-applied, so do not build a "which route is covered" matrix — instead:
- For each
headers()source from Step 1, flag any of the four default header names set to a weaker value (e.g.x-frame-options: ALLOWALL, an over-permissivepermissions-policy,referrer-policy: unsafe-url). Because the defaults are set-when-missing, the route value wins. - Ask how
dist/clientis deployed. Onadapter-node,adapter-cloudflare, or the generated Vercel config, static/ISG responses get the defaults. If a custom CDN or host servesdist/clientdirectly, flag it (warn) and recommend replicating the four defaults in that host's header config.
Step 3: HSTS
Grep for strict-transport-security. If absent everywhere, recommend adding
it (e.g. via a shell headers() export or the host's config):
"strict-transport-security": "max-age=63072000; includeSubDomains; preload"
Only recommend preload if the user confirms they want to commit to HTTPS
permanently and submit to the preload list.
Step 4: Content-Security-Policy
Start from the starter policy in https://pracht.resynapse.dev/docs/recipes/csp, then adjust it for speculation and add observed origins:
- Check which routes use
speculation: read thespeculationfield frompracht inspect routes --json(requires a current@pracht/cli; on older CLIs, grep the manifest forspeculation:instead). Those routes emit an inline<script type="speculationrules">, so add'inline-speculation-rules'toscript-srcunless you use a nonce. - Grep the app for fetch URLs, image URLs, CSS
@import,<script src>,<link href>, font URLs, iframe sources. - Group by directive:
default-src,script-src,style-src,img-src,font-src,connect-src,frame-src; always include'self'per directive, per the starter policy. - Pracht injects hydration state in a non-executable
<script id="pracht-state" type="application/json">— it does not need'unsafe-inline'. Do not add'unsafe-inline'unless the app truly emits executable inline scripts and the tradeoff is documented. - Routes with
streaming: trueemit executable inline scripts for deferred data and boundary swaps. Under a nonce policy, return the request nonce asscriptNoncefrom the shellhead()and allow it inscript-src; it also covers the speculation rules script.
Output a draft CSP referencing https://pracht.resynapse.dev/docs/recipes/csp and explain what each origin is for. Do not hand the user a CSP that breaks their site — present as draft.
Step 5: Prerendered header manifest safety
For SSG/ISG output, the framework refuses to prerender any route whose
document headers include dangerous names (set-cookie, authorization,
proxy-authorization, www-authenticate, proxy-authenticate) or
secret-shaped custom x-* names (token/secret/key/credential patterns) — a
build containing them hard-fails. So do not hunt for those at runtime; audit
the headers() sources statically to catch them before a build failure,
and report each as error with the prerender failure it would cause.
The real target is the warn class the framework cannot catch: innocuously
named headers carrying user-specific values in
dist/server/headers-manifest.json, which serverful adapters may replay across
users on static responses. Every serverful build currently also copies that
manifest into public client output; Cloudflare reads it through the assets
binding, while the other adapters retain the public copy for compatibility. A
pure static export omits the client copy and has no runtime to replay the server
manifest; instead, verify that the deployment's host-header configuration
mirrors only safe, non-user-specific entries. Missing host configuration means
route headers() values are not applied at all.
Secret VALUES in headers are owned by audit-secrets; this skill owns policy
headers. Cross-reference audit-secrets for value-level findings.
Step 6: Cross-origin isolation (optional)
If the user mentions SharedArrayBuffer, WASM threading, or high-precision
timers, recommend:
cross-origin-opener-policy: same-origincross-origin-embedder-policy: require-corpcross-origin-resource-policy: same-originon assets
Otherwise leave these unset — they break embedded third-party content.
Step 7: Report
Two outputs:
- Findings table (source × header × severity).
- Recommendations, in priority order:
error:headers()values that would fail prerender (Step 5).error:headers()exports that weaken a default.warn:dist/clientserved by a host that does not apply the defaults.warn: Missing HSTS in production-grade apps.warn: User-specific headers in prerendered static output.info: CSP draft, COOP/COEP suggestions.
Show the exact code change required (e.g. the headers() export to fix, or
the CDN header config to add).
Rules
- The four defaults are framework-applied — do not recommend re-adding them in app code; audit for weakening and for hosting paths outside the first-party adapters.
- Never recommend a CSP with
'unsafe-eval'unless the user has documented why they need it. applyDefaultSecurityHeadersis set-when-missing, so per-routeheaders()wins over the defaults — weakened values are the primary finding, not missing ones.- Static/ISG responses already get the defaults on first-party adapters and the generated Vercel config; only flag static hosting that bypasses those.
- Treat prerender header manifests as public artifacts.
- Do not auto-edit. Headers are policy; surface gaps, propose patches.
$ARGUMENTS