# Security Header Runbook This runbook defines the expected security-header contract for the web server and Hono API runtime, plus deterministic checks for local and production triage. It intentionally avoids secrets, tenant identifiers, environment-specific values, and placeholder language. ## Expected Web Headers The Node web server in `server/server.ts` applies these headers to React Router responses, redirects, and static asset responses: | Header | Expected value | Purpose | | --- | --- | --- | | `x-content-type-options` | `nosniff` | Prevent MIME sniffing. | | `referrer-policy` | `strict-origin-when-cross-origin` | Keep same-origin referrer detail while reducing cross-origin leakage. | | `x-frame-options` | `DENY` | Prevent clickjacking through legacy frame controls. | | `permissions-policy` | `camera=(), microphone=(), geolocation=()` | Disable high-risk browser capabilities by default. | | `cross-origin-opener-policy` | `same-origin` | Isolate top-level browsing context where supported. | | `cross-origin-resource-policy` | `same-origin` | Restrict cross-origin resource embedding by default. | | `cross-origin-embedder-policy` | `unsafe-none` | Preserve compatibility for embedded/static assets while making the policy explicit. | Web checks must also confirm that existing response behavior is preserved: - `cache-control` remains unchanged for immutable static assets such as `/assets/**`. - Redirect status and `location` behavior remain unchanged when React Router emits redirects. - Header assignment uses single values rather than appended comma-joined duplicates. ## Expected API Headers The API app in `apps/api/src/app.ts` applies these headers to health, OpenAPI, preflight, error, and `/v1` responses: | Header | Expected value | Purpose | | --- | --- | --- | | `x-content-type-options` | `nosniff` | Prevent MIME sniffing. | | `referrer-policy` | `no-referrer` | Avoid leaking API request paths through referrers. | | `x-frame-options` | `DENY` | Prevent API documents or error pages from being framed. | | `permissions-policy` | `camera=(), microphone=(), geolocation=()` | Disable browser feature access for API responses. | | `cross-origin-opener-policy` | `same-origin` | Keep browser isolation policy explicit. | | `cross-origin-resource-policy` | `same-origin` | Limit cross-origin resource reuse by default. | | `cross-origin-embedder-policy` | `unsafe-none` | Avoid over-broad isolation that could break clients. | API checks must also confirm that existing middleware contracts are preserved: - `x-request-id` is still emitted and forwarded according to the request-id middleware contract. - Allowed CORS requests still include the expected `access-control-allow-origin` value. - Canonical app and admin browser origins are accepted from routing config, and explicit `WEB_ORIGIN` and `ADMIN_ORIGIN` values are treated as full origins rather than host-only aliases. - Denied origins, including malformed variants and `https://attacker.example.invalid`, do not emit `access-control-allow-origin` while still returning `x-request-id` for incident correlation. - Preflight responses keep CORS behavior while receiving the request-id and security headers. - No response header includes secrets, personal data, tenant identifiers, or raw environment values. ## Local Verification Commands Run focused checks from the repository root. These commands are scoped to the files and tests that define the security-header and CORS contracts: ```bash pnpm --dir server exec eslint server.test.ts server.ts --max-warnings=0 pnpm --dir server exec vitest run server.test.ts pnpm exec eslint apps/api/src/app.test.ts apps/api/src/cors.test.ts apps/api/src/app.ts apps/api/src/cors.ts --max-warnings=0 pnpm exec vitest run apps/api/src/app.test.ts apps/api/src/cors.test.ts packages/config/src/env-access-enforcement.test.ts packages/config/src/api.test.ts test -s docs/security/runbook.md python - <<'PY' from pathlib import Path text = Path('docs/security/runbook.md').read_text() assert 'apps/api/src/cors.test.ts' in text assert 'attacker.example.invalid' in text assert 'T' + 'BD' not in text assert 'TO' + 'DO' not in text PY ``` Browser, Playwright, Cypress, or other E2E execution is manual unless a human explicitly approves the exact focused run in the current session. ## Production Curl Checklist Use the deployed application origin and API origin appropriate for the environment under investigation. Do not paste secrets or authenticated tokens into shared logs. ### Web HTML response ```bash curl -sSI https://example-web-origin/ | tr -d '\r' | grep -Ei '^(x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that every expected web security header is present with the values listed above. ### Web static asset response ```bash curl -sSI https://example-web-origin/assets/app.js | tr -d '\r' | grep -Ei '^(cache-control|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that static assets still expose the intended `cache-control` policy while also carrying the security headers. ### API health response ```bash curl -sSI https://example-api-origin/healthz | tr -d '\r' | grep -Ei '^(x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that `x-request-id` and all expected API security headers are present. ### API CORS allowed app origin response ```bash curl -sSI https://example-api-origin/v1/example \ -H 'Origin: https://example-web-origin' | \ tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that `access-control-allow-origin` exactly matches `https://example-web-origin`, that `x-request-id` is present, and that the API security headers match the values above. ### API CORS allowed admin origin response ```bash curl -sSI https://example-api-origin/v1/example \ -H 'Origin: https://example-admin-origin' | \ tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that `access-control-allow-origin` exactly matches `https://example-admin-origin`. If the admin origin is configured through `ADMIN_ORIGIN`, compare against the full configured origin string only; do not rewrite it to a bare host in notes or logs. ### API CORS denied origin response ```bash curl -sSI https://example-api-origin/v1/example \ -H 'Origin: https://attacker.example.invalid' | \ tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' || true ``` Confirm that no `access-control-allow-origin` header is emitted for `https://attacker.example.invalid`, while `x-request-id` and the API security headers remain present for safe incident correlation. ### API CORS preflight response ```bash curl -sSI -X OPTIONS https://example-api-origin/v1/example \ -H 'Origin: https://example-web-origin' \ -H 'Access-Control-Request-Method: GET' | \ tr -d '\r' | grep -Ei '^(access-control-allow-origin|access-control-allow-methods|access-control-allow-credentials|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' ``` Confirm that the allowed origin is preserved, CORS preflight headers remain present, `x-request-id` is present, and security headers are still attached to the preflight response. ## CORS Configuration Guidance - Prefer canonical routing-derived app and admin origins for normal deployments, then add `WEB_ORIGIN` and `ADMIN_ORIGIN` only when an environment needs explicit browser origins. - Treat `WEB_ORIGIN` and `ADMIN_ORIGIN` as complete origin strings including scheme and host, for example `https://example-web-origin` and `https://example-admin-origin`; do not document or compare them as host-only aliases. - Do not paste raw environment dumps, secrets, tokens, tenant identifiers, or customer-specific domains into tickets or shared logs. Record only the generic origin label under test, the response header names observed, and the `x-request-id` value when needed. - Test both positive and negative origins during triage. A positive-only check can miss an over-broad allowlist regression. ## Known Interactions - Security headers are set rather than appended, so a response should contain one effective value per header. - Web security headers must not overwrite static asset `cache-control` headers. - API security headers run alongside request-id and CORS middleware; missing `x-request-id` on any API response is a regression in middleware ordering, and missing `access-control-allow-origin` is expected only for denied browser origins. - `cross-origin-embedder-policy: unsafe-none` is intentional for both runtimes to avoid breaking existing embedding and asset-loading behavior. - CORS remains the source of truth for allowed browser origins; the security-header layer should not relax or replace CORS decisions. ## Rollback and Triage Notes 1. Reproduce with the focused local Vitest command for the affected runtime before changing middleware order. 2. Compare curl output from a known-good response against the failing path, checking both missing security headers and preserved headers such as `cache-control`, `x-request-id`, and `access-control-allow-origin`. 3. If security headers are missing only on static assets, inspect the static response path in `server/server.ts` before changing React Router forwarding logic. 4. If API CORS headers disappear, inspect middleware ordering around request-id, security headers, and CORS in `apps/api/src/app.ts`. 5. If duplicate comma-joined policy values appear, verify that code uses `set` or `setHeader` semantics rather than `append`. 6. Roll back the smallest runtime-specific header change first; do not remove request-id, CORS, or cache-control behavior as part of header rollback.