--- id: security-headers-rate-limit-sbom title: Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025 type: prd status: approved author: danijel created: 2026-05-19T11:05:39Z updated: 2026-05-19T11:09:21.438Z --- ## Problem Three load-bearing security primitives the playbook §5, §6, §13 require are missing from the template: - **Security headers** — no app in the template ships HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, or CSP by default. The playbook calls these "mandatory"; security scanners (securityheaders.com, Mozilla Observatory) flag the absence on first scan. Every consumer reinvents the middleware, and getting CSP right with nonces + Sentry replay + Tailwind inline styles is non-trivial enough that most consumers ship `'unsafe-inline'` and call it done. - **Rate-limit primitive** — no template surface for "this endpoint is rate-limited." Auth use cases (signIn, signUp) ship without rate-limit declarations; credential-stuffing and account-enumeration windows stay open until a real consumer notices their auth logs. The playbook §5 says "all authentication endpoints rate-limited per IP and per account" — declaratively impossible today. - **SBOM** — no Software Bill of Materials generated on release. Consumers pursuing SOC 2 / ISO 27001 / FedRAMP / EU CRA evidence have to invent SBOM tooling and bolt it into their release flow. Audit-prep tax compounds across every consumer. ADR-025 settled the strategy: framework-agnostic header middleware in `core-shared/security`, rate-limit as fourth conformance channel in `core-shared/rate-limit`, SBOM via `cyclonedx-npm` in `release-please.yml`. This PRD is the implementation seed for Epic C. ## Goal Ship the three hardening primitives so a downstream consumer gets compliant default headers, manifest-declared rate-limit gates, and per-release SBOM evidence without inventing any of them. Template apps (`web-next`, `web-tanstack`, `cms`) wire the middleware end-to-end; auth feature ships rate-limit as the canonical reference example; release-please attaches a CycloneDX SBOM to every tagged release. ## In scope ### Security headers middleware in `core-shared/security` - New module `core-shared/security/` exporting framework-agnostic header builder: - `buildSecurityHeaders(opts: SecurityHeadersConfig): Record` — returns header name → value map - `SecurityHeadersConfig` type: `{ mode: "production" | "development", nonce?: string, allowedConnectOrigins?, allowedImgOrigins?, allowedFontOrigins?, csp?: { reportUri?: string, reportOnly?: boolean } }` - `generateNonce(): string` — cryptographically random 16-byte base64-encoded - Six headers emitted: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy - Per-framework adapter subpaths (matches `core-analytics/react` pattern): - `core-shared/security/next` — Next.js middleware function + `getNonce()` helper reading from `headers()` - `core-shared/security/tanstack` — TanStack Start server middleware analog + request-context nonce extractor - CSP defaults by mode (auto-switched from `NODE_ENV`): - **Production** — strict CSP with per-request nonce: `default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' {ALLOWED_ORIGINS}; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests;` - **Development** — permissive: `default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;` - App wiring across all three template apps: - `apps/web-next` — `middleware.ts` at app root invoking `core-shared/security/next` - `apps/web-tanstack` — `app.config.ts` server middleware - `apps/cms` — Payload `express` config middleware - Sentry browser SDK nonce-aware init in `apps/web-next/instrumentation-client.ts` + `apps/web-tanstack` equivalent (reads `getNonce()` and threads it into `Sentry.init({ ... })` + replay/feedback integrations) ### Rate-limit primitive in `core-shared/rate-limit` - Scaffolded inline in `core-shared` (small surface; doesn't justify a new optional core) - `IRateLimit` interface — `consume(budgetName, key, weight?): Promise` + `reset(budgetName, key): Promise` + `RateLimitDecision = { allowed, remaining, resetAt }` - Three impls: - `NoopRateLimit` — always-allow default - `InMemoryRateLimit` — per-process Map-backed; dev/single-replica enforcement - `RecordingRateLimit` (lives in `core-testing`) — captures all calls for assertions - `withRateLimit(rateLimit, factory)` wrapper attaching `RateLimited` brand at DI bind time - `RateLimited` brand definition added to `core-shared/conformance/brands.ts` - New per-use-case manifest field `rateLimit: RateLimitBudget[]` where `RateLimitBudget = { name: string, window: string, budget: number }` - New ESLint rule `no-undeclared-rate-limit` (warn severity): - Warn on `rateLimit.consume("X", _)` literal `budgetName` not declared in manifest - Warn on declared budget never consumed - `assertFeatureConformance` extended: require `RateLimited` brand when `rateLimit.length > 0` - Brand composition order updated to innermost: `withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)` - `BindContext` gains `rateLimit?: IRateLimit` (defaults to `NoopRateLimit` when consumer doesn't wire one) - `auth.signIn` backfilled as canonical reference example: - Manifest: `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` - Use case body: dual `consume("ip", ...)` + `consume("account", ...)` calls ### SBOM generation in CI - Amendment to ADR-023 §10 (SBOM bullet) — adds the concrete workflow step - Step added to `.github/workflows/release-please.yml`: - Runs only when release-please cuts a release (`steps.release.outputs.releases_created == 'true'`) - Invokes `pnpm dlx @cyclonedx/cyclonedx-npm` to generate `sbom-.cdx.json` covering the entire workspace via `pnpm-lock.yaml` - Attaches SBOM as a GitHub release asset via `softprops/action-gh-release` (Renovate-pinned SHA per ADR-023) ### Documentation - `docs/guides/security-headers.md` — full cookbook (per-framework wiring, nonce threading for consumer-added inline scripts, CSP allowlist customization, Sentry nonce integration, securityheaders.com verification) - `docs/guides/rate-limiting.md` — cookbook (manifest field, naming convention `::`, multi-budget patterns, dev/staging/prod backend wiring guidance) - `docs/glossary.md` — entries for `RateLimited` brand, `IRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM` (CycloneDX terminology) - `CLAUDE.md` + `conformance-quickref.md` — rule count bump (12 → 13) + new manifest field documentation - `docs/decisions/adr-023-ci-security-and-supply-chain.md` — amendment subsection capturing the SBOM workflow step ## Out of scope - **Compliance fill-in docs (incident runbook, password policy templates, etc.)** — Epic D - **Vendor-specific rate-limit backend** (Redis/Upstash/Cloudflare adapters) — consumer wires via ADR-022 library evaluation - **Tracking IPs for rate-limit keys via request context** — caller-provided keys per Q5 of grill; consumer threads request context into use case input or builds key from controller - **Distributed/multi-replica InMemoryRateLimit semantics** — per-process Map is dev-only; documented as such - **Per-PR SBOM generation** — SBOM is a release artifact, per-release only (Q8 of grill) - **SBOM signing / attestation** — bare SBOM only; SLSA-style provenance attestation is a future PRD if a consumer asks - **CSP report-uri endpoint scaffolding** — CSP supports `report-uri` / `report-to` for violation collection but the template doesn't ship a collector. Config exposes the option; consumer wires their own collector - **Nonce-aware policy for consumer-added inline scripts** — template documents the contract (`getNonce()` helper available; consumer threads via `