10 KiB
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-controlremains unchanged for immutable static assets such as/assets/**.- Redirect status and
locationbehavior 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-idis still emitted and forwarded according to the request-id middleware contract.- Allowed CORS requests still include the expected
access-control-allow-originvalue. - Canonical app and admin browser origins are accepted from routing config, and explicit
WEB_ORIGINandADMIN_ORIGINvalues are treated as full origins rather than host-only aliases. - Denied origins, including malformed variants and
https://attacker.example.invalid, do not emitaccess-control-allow-originwhile still returningx-request-idfor 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:
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
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
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
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
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
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
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
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_ORIGINandADMIN_ORIGINonly when an environment needs explicit browser origins. - Treat
WEB_ORIGINandADMIN_ORIGINas complete origin strings including scheme and host, for examplehttps://example-web-originandhttps://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-idvalue 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-controlheaders. - API security headers run alongside request-id and CORS middleware; missing
x-request-idon any API response is a regression in middleware ordering, and missingaccess-control-allow-originis expected only for denied browser origins. cross-origin-embedder-policy: unsafe-noneis 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
- Reproduce with the focused local Vitest command for the affected runtime before changing middleware order.
- 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, andaccess-control-allow-origin. - If security headers are missing only on static assets, inspect the static response path in
server/server.tsbefore changing React Router forwarding logic. - If API CORS headers disappear, inspect middleware ordering around request-id, security headers, and CORS in
apps/api/src/app.ts. - If duplicate comma-joined policy values appear, verify that code uses
setorsetHeadersemantics rather thanappend. - Roll back the smallest runtime-specific header change first; do not remove request-id, CORS, or cache-control behavior as part of header rollback.