docs: seed PRD for security headers + rate-limit + SBOM (ADR-025 Epic C)
Implementation seed for ADR-025 Epic C: six security headers middleware with nonce-based CSP in core-shared/security (Next + TanStack adapters), fourth conformance channel for rate-limit in core-shared/rate-limit (IRateLimit + RateLimited brand + multi-budget manifest field + no-undeclared-rate-limit ESLint rule), CycloneDX SBOM step in release-please.yml. auth.signIn backfilled as canonical rate-limit reference. ADR-023 amendment for SBOM captured. Status: approved — ready for pnpm work decompose.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updated_at": "2026-05-19T11:06:54.525Z",
|
||||
"updated_at": "2026-05-19T11:09:21.707Z",
|
||||
"epics": {
|
||||
"binder-wrap-helper": {
|
||||
"status": "done",
|
||||
|
||||
419
docs/work/prds/security-headers-rate-limit-sbom.prd.md
Normal file
419
docs/work/prds/security-headers-rate-limit-sbom.prd.md
Normal file
@@ -0,0 +1,419 @@
|
||||
---
|
||||
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<string, string>` — 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<RateLimitDecision>` + `reset(budgetName, key): Promise<void>` + `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-<tag>.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 `<feature>:<scope>:<key>`, 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 `<Script nonce={...}>`) but doesn't enforce
|
||||
- **CSP for the Storybook app** — Storybook's renderer needs `'unsafe-eval'` for some addons; out of scope
|
||||
- **Frame-ancestors override for embeddable widgets** — default `'none'`; consumer overrides when they ship embeddable surfaces
|
||||
- **HSTS preload list submission** — `preload` directive emitted but submission to hstspreload.org is consumer/legal action
|
||||
|
||||
## Constraints
|
||||
|
||||
- **ADR-025** — Epic C strategy settled there.
|
||||
- **ADR-014** — Sentry browser SDK init must respect new nonce contract; can't break existing instrumentation flow.
|
||||
- **ADR-017** — observability PII boundary stays untouched; CSP changes don't affect Sentry's PII scrubbing.
|
||||
- **ADR-021** — release-please's workflow shape is canonical for the SBOM step.
|
||||
- **ADR-022** — no new third-party runtime dependencies for security headers, rate-limit, or SBOM generation. `@cyclonedx/cyclonedx-npm` invoked via `pnpm dlx` (no install). Rate-limit reference impls (`NoopRateLimit`, `InMemoryRateLimit`) use no external libs.
|
||||
- **ADR-023** — SBOM step is an amendment to §10; CI workflow changes follow the established SHA-pin pattern.
|
||||
- **ADR-024** + Epic B `requiresConsent`\*\* — rate-limit wrapper composes innermost (after consent); the order is canonical and tested.
|
||||
- **Generator-first** — `withRateLimit` wrapper, `RateLimited` brand, ESLint rule additions follow the established conformance scaffold pattern.
|
||||
- **Manifest-first ordering** — manifest schema + types land first; ESLint rule second; wrapper third; backfill (`auth.signIn`) fourth; app wiring last.
|
||||
- **`core-shared` boundary** — rate-limit + security live in `core-shared` (must-have, available to every consumer). No new optional cores.
|
||||
- **Conventional Commits** — every slice = one green commit.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `pnpm dev` on any app emits all six security headers with mode=development CSP; browser dev tools show `Content-Security-Policy` and the other 5 headers on every response.
|
||||
- `pnpm build && pnpm start` (production mode) on any app emits mode=production CSP with a unique nonce per request, and Sentry browser SDK initializes successfully (no CSP violations in browser console).
|
||||
- `securityheaders.com` scan of a `pnpm start`-served app scores A or A+.
|
||||
- `core-shared/security/next` exports a working Next.js middleware that integrates with the app's existing middleware chain (auth checks etc.).
|
||||
- `core-shared/security/tanstack` exports a working TanStack Start adapter.
|
||||
- `IRateLimit` interface has three working impls (Noop, InMemory, Recording) with passing tests.
|
||||
- `auth.signIn` manifest declares `rateLimit: [{ name: "ip", ... }, { name: "account", ... }]`; use case body invokes both `consume` calls; binding fails `assertFeatureConformance` if `withRateLimit` is omitted.
|
||||
- `no-undeclared-rate-limit` ESLint rule fires on `rateLimit.consume("foo", ...)` where `"foo"` is not declared in the manifest; passes on matching calls.
|
||||
- A merged release-please PR cuts a tag AND the release-please workflow uploads a CycloneDX SBOM as a release asset; the asset opens in any CycloneDX viewer.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
|
||||
- `docs/guides/security-headers.md` + `docs/guides/rate-limiting.md` cover consumer wiring end-to-end including the Sentry nonce thread and the canonical key-naming convention.
|
||||
- CLAUDE.md + `conformance-quickref.md` reflect 13 conformance ESLint rules.
|
||||
|
||||
## User stories
|
||||
|
||||
1. As a **downstream consumer**, I want six security headers shipped by default in all three template apps so a securityheaders.com scan immediately scores A/A+ without my writing middleware.
|
||||
2. As a **downstream consumer running Next.js**, I want a per-request CSP nonce so I can use Next.js's `<Script>` component without falling back to `'unsafe-inline'`.
|
||||
3. As a **downstream consumer running TanStack Start**, I want the same nonce contract as Next.js so my dual-framework deployment behaves consistently.
|
||||
4. As a **downstream consumer**, I want CSP to auto-switch between strict (production) and permissive (development) so my dev tooling (HMR, inline-style libraries) keeps working without manual config.
|
||||
5. As a **downstream consumer customizing CSP**, I want `allowedConnectOrigins` + `allowedImgOrigins` config so I can allowlist my Sentry DSN host, analytics backend, CDN, etc. without forking the builder.
|
||||
6. As a **downstream consumer shipping auth**, I want `rateLimit: [{ name: "ip" }, { name: "account" }]` on signIn so credential stuffing fails fast at lint time if I forget to wire it.
|
||||
7. As a **downstream consumer**, I want `IRateLimit` to support multiple named budgets per use case so I can express "5/min per IP AND 10/hour per account" without homegrown key concatenation.
|
||||
8. As an **AI agent** modifying signIn's use case body, I want `no-undeclared-rate-limit` to fire when I call `rateLimit.consume("foo", ...)` without declaring `"foo"` in manifest, so rate-limit drift is caught at lint time.
|
||||
9. As an **AI agent** scaffolding a new auth/write/export use case, I want manifest to default to `rateLimit: []` so I'm prompted to think about rate-limit during manifest-first ordering.
|
||||
10. As a **downstream consumer running InMemoryRateLimit in dev**, I want predictable single-process enforcement so I can test 429 responses locally without spinning up Redis.
|
||||
11. As an **SRE rolling out production**, I want a Redis-backed `IRateLimit` impl I can swap in via `BindContext.rateLimit` so my multi-replica deployment shares state.
|
||||
12. As a **compliance officer pursuing SOC 2**, I want a CycloneDX SBOM attached to every GitHub release so my auditor can answer "what's in version X" without inventory inspection.
|
||||
13. As a **downstream consumer running serverless**, I want SBOM generation to happen at release time only (not per-PR) so my CI minutes don't balloon.
|
||||
14. As an **AI agent** sequencing the dispatch, I want Epic C stories independent of Epic A/B so I can interleave with the dependency-ordered chain.
|
||||
|
||||
## Implementation decisions
|
||||
|
||||
### Module surface
|
||||
|
||||
- **`@repo/core-shared` additions** (must-have package, no new optional core):
|
||||
- New module `core-shared/security/` containing:
|
||||
- `security-types.ts` — `SecurityHeadersConfig`, `CspMode`, `CspDirective` types
|
||||
- `build-security-headers.ts` — pure builder function
|
||||
- `nonce.ts` — `generateNonce()` cryptographic helper
|
||||
- `next/index.ts` — Next.js middleware adapter + `getNonce()` from `headers()`
|
||||
- `tanstack/index.ts` — TanStack Start adapter
|
||||
- New module `core-shared/rate-limit/` containing:
|
||||
- `rate-limit.interface.ts` — `IRateLimit`, `RateLimitBudget`, `RateLimitDecision` types
|
||||
- `noop-rate-limit.ts` + sibling test
|
||||
- `in-memory-rate-limit.ts` + sibling test
|
||||
- `with-rate-limit.ts` — wrapper attaching `RateLimited` brand
|
||||
- Extension to `core-shared/conformance/brands.ts`:
|
||||
- `RateLimited<F> = F & { readonly __rateLimited: true }` type
|
||||
- Helper `isRateLimited(fn): boolean`
|
||||
- Extension to `core-shared/conformance/wire-use-case.ts`:
|
||||
- Accept optional `rateLimit: IRateLimit` arg; compose `withRateLimit` innermost when manifest declares `rateLimit.length > 0`
|
||||
- Extension to `core-shared/conformance/assert-bindings.ts`:
|
||||
- Require `RateLimited` brand when `manifest.useCases[name].rateLimit.length > 0`
|
||||
- Extension to `core-shared/conformance/define-feature.ts`:
|
||||
- Type `UseCaseManifest.rateLimit?: RateLimitBudget[]`
|
||||
- Extension to `core-shared/di/bind-context.ts`:
|
||||
- `BindContext.rateLimit?: IRateLimit` (defaults to `NoopRateLimit` at app aggregator level)
|
||||
- **`@repo/core-testing` additions**:
|
||||
- `recording-rate-limit.ts` — captures `consume` + `reset` invocations
|
||||
- **`@repo/core-eslint` additions**:
|
||||
- New rule `no-undeclared-rate-limit.js` (warn severity)
|
||||
- `_manifest-ast.js` parser gains `rateLimit` field extraction
|
||||
- `plugin.js` + `base.js` register the rule at warn
|
||||
- **`packages/auth/` modifications**:
|
||||
- `feature.manifest.ts` — add `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` to `signIn`
|
||||
- `application/use-cases/sign-in.use-case.ts` — body invokes both `rateLimit.consume` calls; deps signature gains `rateLimit: IRateLimit`
|
||||
- `di/bind-production.ts` + `bind-dev-seed.ts` — pass `ctx.rateLimit` (or `new NoopRateLimit()` fallback) into `wireUseCase` for signIn
|
||||
- **`apps/web-next` modifications**:
|
||||
- `middleware.ts` — invokes `core-shared/security/next` middleware; chains with existing auth checks
|
||||
- `instrumentation-client.ts` — reads nonce via `getNonce()`, passes to `Sentry.init({ ..., transportOptions, integrations })`
|
||||
- `app/layout.tsx` — threads nonce into `<Script>` tags (Sentry, analytics, any inline scripts)
|
||||
- **`apps/web-tanstack` modifications**:
|
||||
- `app.config.ts` — register server middleware from `core-shared/security/tanstack`
|
||||
- Equivalent client init file (`src/client.tsx` or framework convention) — nonce-aware Sentry init
|
||||
- **`apps/cms` modifications**:
|
||||
- Payload config — wire Express middleware from `core-shared/security` (cms is server-side only; no nonce concern)
|
||||
- **`.github/workflows/release-please.yml` modifications**:
|
||||
- Conditional SBOM generation step after release-creation
|
||||
- SBOM upload to GitHub release via `softprops/action-gh-release`
|
||||
- **`docs/decisions/adr-023-ci-security-and-supply-chain.md`**:
|
||||
- Amendment subsection capturing the SBOM workflow step concrete shape
|
||||
- **`docs/guides/`**:
|
||||
- New: `security-headers.md`
|
||||
- New: `rate-limiting.md`
|
||||
- **`docs/glossary.md`**:
|
||||
- Entries: `IRateLimit`, `RateLimited` brand, `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM`, `nonce` (CSP context)
|
||||
- **`CLAUDE.md` + `docs/guides/conformance-quickref.md`**:
|
||||
- Rule count bump: 12 → 13
|
||||
- New manifest field `rateLimit` documented in conformance table
|
||||
|
||||
### Type primitive contracts (inlined where decision-encoding tight)
|
||||
|
||||
```ts
|
||||
// core-shared/security/security-types.ts
|
||||
export type CspMode = "production" | "development";
|
||||
|
||||
export type SecurityHeadersConfig = {
|
||||
mode: CspMode;
|
||||
nonce?: string;
|
||||
allowedConnectOrigins?: string[];
|
||||
allowedImgOrigins?: string[];
|
||||
allowedFontOrigins?: string[];
|
||||
csp?: {
|
||||
reportUri?: string;
|
||||
reportOnly?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// core-shared/rate-limit/rate-limit.interface.ts
|
||||
export type RateLimitBudget = {
|
||||
name: string;
|
||||
window: string; // ISO 8601 duration, e.g. "P1M" or shorthand "1m" / "1h"
|
||||
budget: number;
|
||||
};
|
||||
|
||||
export type RateLimitDecision = {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
};
|
||||
|
||||
export interface IRateLimit {
|
||||
consume(
|
||||
budgetName: string,
|
||||
key: string,
|
||||
weight?: number,
|
||||
): Promise<RateLimitDecision>;
|
||||
reset(budgetName: string, key: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// auth.signIn — canonical example
|
||||
// feature.manifest.ts entry:
|
||||
signIn: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
analyticsEvents: [],
|
||||
requiresConsent: ["essential"],
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
],
|
||||
}
|
||||
|
||||
// use case body:
|
||||
export const signInUseCase = (deps) => async (input) => {
|
||||
const ipDecision = await deps.rateLimit.consume("ip", `signIn:ip:${input.clientIp}`);
|
||||
if (!ipDecision.allowed) throw new TooManyRequestsError("ip");
|
||||
const accountDecision = await deps.rateLimit.consume("account", `signIn:account:${input.email}`);
|
||||
if (!accountDecision.allowed) throw new TooManyRequestsError("account");
|
||||
// ... rest of business logic
|
||||
};
|
||||
```
|
||||
|
||||
### CSP defaults
|
||||
|
||||
**Production CSP (template emits with 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_CONNECT_ORIGINS};
|
||||
frame-ancestors 'none';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
upgrade-insecure-requests;
|
||||
```
|
||||
|
||||
`'strict-dynamic'` + nonce: nonce-tagged scripts can load further scripts dynamically without enumeration. Required for Sentry's lazy-loaded modules. `'unsafe-inline'` in `style-src` is a known compromise (Tailwind, CSS-in-JS, framework runtime inject inline styles); production CSP eliminates `'unsafe-inline'` for `script-src` (the actually-dangerous one).
|
||||
|
||||
**Development CSP:**
|
||||
|
||||
```
|
||||
default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;
|
||||
```
|
||||
|
||||
Allows Next.js HMR (websocket), React Refresh (unsafe-eval), and inline scripts. Auto-emitted when `mode === "development"`.
|
||||
|
||||
### Sentry nonce integration
|
||||
|
||||
Per-framework integration:
|
||||
|
||||
- **Next.js** (`apps/web-next/instrumentation-client.ts`):
|
||||
```ts
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
Sentry.init({
|
||||
dsn: ...,
|
||||
integrations: [
|
||||
Sentry.replayIntegration({ ..., nonce }),
|
||||
Sentry.feedbackIntegration({ ..., nonce }),
|
||||
],
|
||||
});
|
||||
```
|
||||
- **TanStack Start** — equivalent via request-context extraction
|
||||
- **Layout / Document head** — `<Script nonce={nonce}>` for any inline scripts the app ships
|
||||
|
||||
The `getNonce()` helper exported from `core-shared/security/next` abstracts the framework-specific extraction (`headers().get("x-nonce")` in Next.js Server Components).
|
||||
|
||||
### Rate-limit wrapper composition
|
||||
|
||||
Composition order (innermost → outermost):
|
||||
|
||||
```
|
||||
withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ withRateLimit ⟶ factory(deps)
|
||||
```
|
||||
|
||||
Rate-limit innermost because:
|
||||
|
||||
- Rate-limit fires after every other gate (audit, span, capture). The body running rate-limit `consume` calls is the actual work; outer layers wrap the work, including the rate-limit check.
|
||||
- Spans should include rate-limit decision latency for observability — if `withSpan` were inside `withRateLimit`, the span would miss the rate-limit decision.
|
||||
|
||||
### CI workflow amendment (ADR-023 §10)
|
||||
|
||||
`.github/workflows/release-please.yml` gains a step after the release-please action emits `releases_created`:
|
||||
|
||||
```yaml
|
||||
- name: Generate SBOM
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
run: pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json --output-format json
|
||||
- name: Upload SBOM to GitHub release
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
uses: softprops/action-gh-release@<SHA>
|
||||
with:
|
||||
files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
```
|
||||
|
||||
`<SHA>` is Renovate-managed per ADR-023. `pnpm dlx` avoids adding cyclonedx-npm to the lockfile (it's a CI-only tool, not a runtime dep).
|
||||
|
||||
Per-package SBOMs aren't generated — release-please cuts multiple per-package tags simultaneously, but the root SBOM covers all workspace packages and is the canonical evidence per industry practice.
|
||||
|
||||
### Conformance impact
|
||||
|
||||
- ESLint rule count: 12 → 13 (`no-undeclared-rate-limit`)
|
||||
- Manifest field: `rateLimit?: RateLimitBudget[]` (optional, defaults to `[]`)
|
||||
- New brand: `RateLimited`
|
||||
- Boot assertion: extended to require `RateLimited` brand when `rateLimit.length > 0`
|
||||
- New manifest channel doesn't require a new optional core (`core-shared/rate-limit` is must-have)
|
||||
|
||||
## Testing decisions
|
||||
|
||||
- **`core-shared/security`**:
|
||||
- Unit tests on `buildSecurityHeaders`: returns expected header set per mode, nonce threaded into CSP when provided, optional config fields applied to CSP allowlists, dev vs prod CSP shape
|
||||
- Unit test on `generateNonce`: returns base64-encoded 16-byte strings, two calls return different values
|
||||
- Adapter tests for `core-shared/security/next`: middleware sets headers + injects nonce into response headers + makes nonce available to `getNonce()` helper
|
||||
- Adapter tests for `core-shared/security/tanstack`: equivalent
|
||||
- **`core-shared/rate-limit`**:
|
||||
- `NoopRateLimit` test: `consume` always returns `{ allowed: true }`, `reset` is no-op
|
||||
- `InMemoryRateLimit` test: tracks per-bucket counts correctly, decrements on consume, resets at window boundary, resets to budget on explicit `reset` call, concurrent consumes are safe (single-threaded JS — straightforward)
|
||||
- `RecordingRateLimit` test: captures invocation arguments verbatim
|
||||
- `withRateLimit` wrapper test: `RateLimited` brand attached, factory invocation passthrough preserved, composes with other wrappers
|
||||
- **`assertFeatureConformance` extension**:
|
||||
- Synthetic manifest fixture with `rateLimit: [{ name: "ip", ... }]` but no `withRateLimit` at bind: fails with `ConformanceError` naming the missing brand
|
||||
- **`no-undeclared-rate-limit` ESLint rule**:
|
||||
- RuleTester fixtures parallel to `no-undeclared-audit.test.js`
|
||||
- Passes when call's `budgetName` matches declared
|
||||
- Fires on undeclared `budgetName`
|
||||
- Fires on declared-but-unused (warn)
|
||||
- No-op on non-use-case files
|
||||
- **`auth.signIn` integration**:
|
||||
- Existing signIn test extended: with `RecordingRateLimit`, assert dual-consume captured; with `InMemoryRateLimit` set to budget 1, second call fails with `TooManyRequestsError`
|
||||
- **App integration**:
|
||||
- `apps/web-next/middleware.test.ts` (or equivalent): assertion that all six headers present in response, CSP shape correct for `NODE_ENV=production` + `NODE_ENV=development`, nonce header present in response
|
||||
- Manual verification: `pnpm build && pnpm start`, hit `localhost:3000`, browser dev tools shows all headers + functioning Sentry replay (no CSP violations)
|
||||
- **SBOM workflow**:
|
||||
- Verify locally: `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-test.cdx.json` succeeds and produces valid CycloneDX JSON
|
||||
- End-to-end verification: merge a release-please PR, confirm SBOM asset appears on the resulting GitHub release
|
||||
- **Coverage**: all new modules join L0 vitest thresholds per their `coverage.bands`. L1 `pnpm coverage:diff` gates every slice.
|
||||
- **Prior art to mirror**:
|
||||
- Wrapper + brand pattern: `core-shared/instrumentation/with-capture.{ts,test.ts}` + `core-shared/conformance/wire-use-case.{ts,test.ts}`
|
||||
- ESLint rule shape: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
|
||||
- Three-impl interface pattern: `core-shared/jobs/{job-queue.interface.ts, payload-job-queue.ts, in-memory-job-queue.ts}` + `core-testing/instrumentation/recording-job-queue.ts`
|
||||
- Per-framework adapter subpath: `core-analytics/react` from ADR-024 (ships React provider as subpath)
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1: Should `InMemoryRateLimit` honor TTL via `setTimeout` or check-at-read for bucket expiry?** — Recommended: **check-at-read**. setTimeout floods the event loop with timers in busy apps. Check-at-read on every `consume` call evaluates `now() > resetAt` and resets if expired. Single test case: assert bucket resets after `window` elapses with synthetic clock injection.
|
||||
- **Q2: Does `withRateLimit` short-circuit the use case body on `!allowed`, or does the body decide?** — Recommended: **body decides**. The wrapper attaches the brand only; the use case body calls `consume(...)` explicitly and decides whether to throw `TooManyRequestsError` or degrade gracefully. Mirrors the audit pattern (`auditLog.record` doesn't decide flow control).
|
||||
- **Q3: Should the security headers builder validate that `allowedConnectOrigins` entries are well-formed URLs?** — Recommended: **yes, with `URL` constructor parse**. Throws `InvalidSecurityHeadersConfig` at app boot if an allowlisted origin is malformed. Catches typos early. Documented in `docs/guides/security-headers.md`.
|
||||
- **Q4: For TanStack Start nonce: where does the app store/retrieve the nonce — request context, async-local-storage, or response header?** — Recommended: **request context via TanStack's existing mechanisms**. Use `useRouterState()` or context provider for client-side access; server middleware reads from `event.node.req.headers["x-nonce"]` after setting it. Document the framework-specific extraction in `docs/guides/security-headers.md`.
|
||||
- **Q5: How does CSP interact with `apps/storybook`?** — Recommended: **out of Epic C scope** (called out in "Out of scope"). Storybook's renderer needs `'unsafe-eval'`; shipping strict CSP there breaks docs. Document the omission.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **Compliance fill-in docs (incident runbook, password policy, etc.)** — Epic D
|
||||
- **Redis-backed `IRateLimit` reference impl** — consumer wires via ADR-022 library evaluation (`@upstash/ratelimit` or similar would need a trace)
|
||||
- **CSP report-uri collector endpoint** — consumer-side; could be added later
|
||||
- **SBOM signing / SLSA attestation** — bare CycloneDX SBOM only; signed attestations are a future PRD
|
||||
- **Per-PR SBOM generation** — release-only per Q8 of grill
|
||||
- **`apps/storybook` CSP** — Storybook tooling requires `'unsafe-eval'`; explicit deferral
|
||||
- **`IRateLimit` v2: token-bucket algorithm vs fixed-window** — InMemoryRateLimit uses fixed-window for simplicity; consumer's production backend likely uses token-bucket; documented as such
|
||||
- **CSP nonce reuse strategy** — every request gets a fresh nonce; no reuse across requests. Don't optimize prematurely.
|
||||
- **HSTS preload list submission** — `preload` directive emitted but hstspreload.org submission is consumer/legal action
|
||||
|
||||
## Further notes
|
||||
|
||||
- **Builds on**: ADR-014 (Sentry observability — nonce integration), ADR-017 (PII boundary — CSP shouldn't affect server-side scrubbing), ADR-021 (release-please — SBOM step extends), ADR-022 (library evaluation — no new traces needed), ADR-023 (CI security — SBOM amendment), ADR-024 (analytics — confirms wrapper composition order), ADR-025 (strategy umbrella).
|
||||
- **Pairs with**: Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — no direct dependency but Epic C's `auth.signIn` rate-limit backfill follows Epic A's backfill pattern), Epic B PRD (`dsr-consent-and-cookie-banner.prd.md` — wrapper composition order extended consistently), Epic D PRD (`compliance-docs-scaffolds.prd.md` — references Epic C's middleware + rate-limit + SBOM in security checklist documents).
|
||||
- **Sequencing within Epic C**: type primitives (`SecurityHeadersConfig`, `RateLimitBudget`, `IRateLimit`) → manifest schema + ESLint rule → wrapper + brand + `wireUseCase` extension → `auth.signIn` backfill → security headers builder + per-framework adapters → app integration (web-next middleware + Sentry nonce, web-tanstack same, cms middleware) → SBOM workflow step + ADR-023 amendment → docs.
|
||||
- **Dispatch independence**: Epic C has no dependencies on Epic A's deliverables or Epic B's; the dispatch loop can interleave Epic C stories with Epic B's whenever an Epic B story is blocked.
|
||||
- **Stakeholders**: template authors (most affected — adds manifest field + brand + ESLint rule + ADR-023 amendment), downstream consumers (positively affected — gain compliant headers + rate-limit + SBOM out of box), AI agents operating in feature code (positively affected — declarative rate-limit gates with lint-time enforcement), compliance officers (positively affected — SBOM evidence per release; audit-grade headers), SREs (positively affected — IRateLimit interface for production wiring).
|
||||
Reference in New Issue
Block a user