set-up-security-headers · diff
git:20260609.c2107a9 to git:20261009.0ae007b
102 added, 22 removed. Audit A to B.
---
name: set-up-security-headers
- description: Use when hardening a frontend SPA's delivery — defines a Content-Security-Policy and standard security headers delivered at the edge via Netlify, dependency hygiene via Dependabot, and an honest note on the XSS surface.
+ description: Use when hardening a frontend SPA's delivery — one strict Content-Security-Policy and header set (no unsafe-inline scripts), rendered for the project's host (Netlify, Vercel, or self-hosted Caddy/nginx/Traefik), plus Dependabot hygiene and an honest note on the XSS surface.
---
# Set Up Security Headers
- Security headers are delivered by the host, not the bundle. This skill writes a strict, SPA-appropriate header set for Netlify and wires the CSP's `connect-src` to the origins the app actually calls.
+ Security headers are delivered by the host, not the bundle. The policy is the same on every host; only the file that carries it changes. This skill writes the policy once (step 3), renders it for the project's host (step 4), and wires `connect-src` to the origins the app actually calls.
## 1. Audit current state
```bash
- ls netlify.toml public/_headers .github/dependabot.yml 2>/dev/null
- grep -in "content-security-policy\|strict-transport" netlify.toml public/_headers 2>/dev/null
+ cat .claude/stack-profile.md 2>/dev/null || cat ~/.claude/stack-profile.md 2>/dev/null # hosting, ci
+ ls netlify.toml public/_headers vercel.json vercel.ts Caddyfile nginx.conf compose*.y*ml .github/dependabot.yml 2>/dev/null
+ grep -in "content-security-policy\|strict-transport" netlify.toml public/_headers vercel.json vercel.ts Caddyfile nginx.conf 2>/dev/null
grep -n "VITE_API_URL\|VITE_REALTIME_URL" src/libs/env.ts 2>/dev/null # origins for connect-src
+ grep -n "<script>" index.html 2>/dev/null # inline scripts break script-src 'self'
```
- **Prerequisites:** a Netlify deploy (see `configure-ci`). Recommended: `validate-env` (the CSP reads `VITE_API_URL` / `VITE_REALTIME_URL`).
+ Pick the host branch from the profile's `hosting` list. If there is no profile, detect it from the files above.
+ | Profile `hosting` / evidence | Branch |
+ |---|---|
+ | `netlify`, or `netlify.toml` | Netlify (4a) |
+ | `vercel`, or `vercel.json` / `vercel.ts` | Vercel (4b) |
+ | `hetzner`, `ionos`, or a Caddyfile / nginx conf / Traefik labels | Self-hosted (4c) |
+ | `supabase`, `none`, or unknown | Ask one question: where is the SPA served from? Do not guess. |
+
+ **Prerequisites:** a deployed host (see `configure-ci`). Recommended: `validate-env` (the CSP reads `VITE_API_URL` / `VITE_REALTIME_URL`).
+
## 2. Decide
- No headers → full setup. Partial → add the missing headers/directives. Present → confirm `connect-src` covers current API + realtime origins.
+ - An inline `<script>` in `index.html` (theme init, analytics snippet) → move it to a same-origin file in `public/` first. The policy below blocks inline scripts on purpose.
- ## 3. The header block (Netlify)
- Add to `netlify.toml` (create if absent — this is the `[[headers]]` block; `configure-ci` owns the `[build]` block, so merge, don't overwrite):
+ ## 3. The policy — one definition, every host
+ | Header | Value |
+ |---|---|
+ | `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'` |
+ | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` (add `; preload` only after the HSTS-preload decision in `security-patterns.md`) |
+ | `X-Content-Type-Options` | `nosniff` |
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` |
+ | `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |
+
+ Replace `https://api.example.com` / `wss://realtime.example.com` with the **origins** of `VITE_API_URL` / `VITE_REALTIME_URL` (scheme + host, no path). Drop the `wss:` origin if realtime isn't set up. Cache rule for every host: `index.html` and the service worker (`sw.js`) must not be cached long-term (`Cache-Control: no-cache`); hashed `/assets/*` can be `immutable`. The SPA fallback (all unknown paths → `/index.html`) is host config too; each block below includes it.
+
+ ## 4. Render it for the host (a project uses exactly one block)
+
+ ### 4a. Netlify
+ `netlify.toml` — the `[[headers]]` block (`configure-ci` owns `[build]`; merge, don't overwrite):
```toml
[[headers]]
for = "/*"
[headers.values]
- Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
- Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload"
+ Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
+ Strict-Transport-Security = "max-age=31536000; includeSubDomains"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
+
+ [[headers]]
+ for = "/sw.js"
+ [headers.values]
+ Cache-Control = "no-cache"
```
- Replace `https://api.example.com` / `wss://realtime.example.com` with the **origins** of `VITE_API_URL` / `VITE_REALTIME_URL` (scheme + host, no path). Drop the `wss:` origin if realtime isn't set up.
- ## 4. CSP notes for a Vite SPA
- - Vite emits **external** scripts in prod, so `script-src 'self'` needs no `'unsafe-inline'`. (These headers apply to the deployed build; the dev server is looser.)
- - `style-src` keeps `'unsafe-inline'` because Tailwind/runtime styles inject inline `<style>`. To drop it, adopt a nonce/hash — see `security-patterns.md`.
+ ### 4b. Vercel
+ `vercel.json` (use `vercel.ts` with `routes.header()` from `@vercel/config/v1` only if the project already has one — a project may hold one config file, not both):
+ ```json
+ {
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
+ "headers": [
+ {
+ "source": "/(.*)",
+ "headers": [
+ { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
+ { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
+ { "key": "X-Content-Type-Options", "value": "nosniff" },
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
+ { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
+ ]
+ },
+ { "source": "/sw.js", "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }] }
+ ]
+ }
+ ```
+ Preview deploys and project setup: [`../../infra/deploy-to-vercel/SKILL.md`](../../infra/deploy-to-vercel/SKILL.md). Vercel serves files from the filesystem before it applies the rewrite, so `/assets/*` still resolves.
+
+ ### 4c. Self-hosted (Hetzner / IONOS)
+ Caddy serves the built `dist/` and carries the headers (a Caddy container behind or instead of Traefik):
+ ```caddyfile
+ example.com {
+ root * /srv
+ encode zstd gzip
+ header {
+ Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+ X-Content-Type-Options nosniff
+ Referrer-Policy strict-origin-when-cross-origin
+ Permissions-Policy "camera=(), microphone=(), geolocation=()"
+ }
+ @shell path /index.html /sw.js
+ header @shell Cache-Control "no-cache"
+ try_files {path} /index.html
+ file_server
+ }
+ ```
+ nginx: the same values as `add_header <name> "<value>" always;` in the `server` block. An `add_header` inside a `location` block **replaces** every inherited one — repeat the full set there or use an `include`. Traefik in front of a plain file server: use the `headers` middleware (`stsSeconds=31536000`, `stsIncludeSubdomains=true`, `contentTypeNosniff=true`, `referrerPolicy`, `contentSecurityPolicy`, `customResponseHeaders.Permissions-Policy`) and attach it to the router. Server, deploy and TLS: [`../../infra/deploy-to-hetzner/SKILL.md`](../../infra/deploy-to-hetzner/SKILL.md).
+
+ ## 5. CSP notes for a Vite SPA
+ - Vite emits **external** scripts in prod, so `script-src 'self'` needs no `'unsafe-inline'`. The dev server is looser; the policy applies to the deployed build.
+ - `style-src 'unsafe-inline'` is a pragmatic default, not a requirement: the Tailwind build is an external CSS file, and React/Vue set `style` through the CSSOM, which CSP allows. Libraries that inject `<style>` tags at runtime need it. Test `style-src 'self'` in Report-Only first (`security-patterns.md`).
- `connect-src` breaks silently: omit your API or `wss://` origin and those requests fail with a console CSP error. That's why it reads from the env seam.
+ - Trusted Types (`require-trusted-types-for 'script'`) is the next layer. Roll it out in Report-Only (`security-patterns.md`).
- ## 5. Dependency hygiene
+ ## 6. Dependency hygiene
`.github/dependabot.yml`:
```yaml
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
groups:
minor-and-patch:
update-types: [minor, patch]
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
```
- Pair with a `pnpm audit` step in CI (`configure-ci`). Automation proposes; a human approves each bump.
+ `configure-ci` runs `pnpm audit`. Automation proposes; a human approves each bump.
- ## 6. The XSS surface (the honest note)
- React and Vue escape interpolated values by default. The real hole is **`dangerouslySetInnerHTML` (React) / `v-html` (Vue)** — they bypass escaping. Avoid them; if unavoidable, sanitize with DOMPurify first. A strict CSP is defense-in-depth, not a substitute.
+ ## 7. The XSS surface (the honest note)
+ React and Vue escape interpolated values by default. The real hole is **`dangerouslySetInnerHTML` (React) / `v-html` (Vue)** — they bypass escaping. Avoid them; if unavoidable, sanitize with DOMPurify first. A strict CSP is defense-in-depth, not a substitute. Baseline: [`security-baseline.md`](../../core/_shared/security-baseline.md).
- ## 7. Verify
+ ## 8. Verify
```bash
- # after a deploy preview is live:
- curl -sI https://<deploy-preview-url>/ | grep -i "content-security-policy\|strict-transport"
+ curl -sI https://<deploy-url>/ | grep -i "content-security-policy\|strict-transport\|x-content-type"
+ curl -sI https://<deploy-url>/sw.js | grep -i cache-control # if the PWA is on
+ curl -s -o /dev/null -w "%{http_code}\n" https://<deploy-url>/some/deep/route # 200 = SPA fallback works
```
Load the app: the console shows **no** CSP violations (every API / realtime / font / image origin is allowed). Grade it at securityheaders.com.
## References
- - ./security-patterns.md — edge-vs-meta delivery, the `connect-src`-from-env rule, the inline-style nonce path, dependency hygiene, the XSS surface, and other hosts.
+ - ./security-patterns.md — edge-vs-meta delivery, the `connect-src`-from-env rule, strict-CSP rollout (Report-Only, Trusted Types), the HSTS-preload decision, supply chain, the XSS surface.
- ../validate-env/SKILL.md — the `env` seam supplying the `connect-src` origins.
- - ../configure-ci/SKILL.md — owns the `netlify.toml` `[build]` block and the `pnpm audit` step.
+ - ../configure-ci/SKILL.md — owns the Netlify `[build]` block and the `pnpm audit` step.
+ - ../../core/_shared/security-baseline.md — the cross-stack header and cookie rules this skill implements.