docs(security): add security-headers and rate-limiting cookbooks
Adds two new consumer-facing guides: - docs/guides/security-headers.md: per-framework middleware wiring (Next.js, TanStack Start, Payload CMS), nonce threading for inline scripts, CSP allowlist customisation, Sentry nonce integration, and securityheaders.com verification workflow. - docs/guides/rate-limiting.md: manifest rateLimit field declaration, canonical key-naming convention (<feature>:<scope>:<key>), multi-budget patterns, InMemoryRateLimit / NoopRateLimit for dev/test, and production backend wiring via BindContext.rateLimit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
303
docs/guides/rate-limiting.md
Normal file
303
docs/guides/rate-limiting.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Rate limiting cookbook
|
||||
|
||||
Rate limiting is declared in the feature manifest, enforced at the use-case level via `IRateLimit`, and verified at boot time by `assertFeatureConformance`. This guide covers the manifest declaration, key-naming convention, multi-budget patterns, and backend wiring for each environment.
|
||||
|
||||
---
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
feature.manifest.ts
|
||||
└── rateLimit: [{ name, window, budget }, ...]
|
||||
│
|
||||
▼
|
||||
wireUseCase({ rateLimit: ctx.rateLimit ?? new NoopRateLimit() })
|
||||
└── withRateLimit(rateLimit, factory(deps)) ← attaches __rateLimited brand
|
||||
│
|
||||
▼
|
||||
assertFeatureConformance(container, manifest, symbols, ctx)
|
||||
└── checks __rateLimited brand when manifest.rateLimit.length > 0
|
||||
```
|
||||
|
||||
The conformance rule `no-undeclared-rate-limit` (ESLint, warn severity) verifies that every `rateLimit.consume("X", …)` call in a use-case file has a matching `{ name: "X" }` budget in the manifest, and that every declared budget is actually consumed.
|
||||
|
||||
---
|
||||
|
||||
## Manifest field declaration
|
||||
|
||||
Add `rateLimit` to the use-case entry inside `feature.manifest.ts`:
|
||||
|
||||
```ts
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
export const fooManifest = defineFeature({
|
||||
name: "foo",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
submitOrder: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 10 },
|
||||
{ name: "user", window: "1h", budget: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
```
|
||||
|
||||
### `RateLimitBudget` fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `name` | `string` | Budget identifier; matches the first argument of `rateLimit.consume()` |
|
||||
| `window` | `string` | Rolling window duration. Accepted units: `ms`, `s`, `m`, `h`, `d` |
|
||||
| `budget` | `number` | Maximum requests (weight units) allowed within the window |
|
||||
|
||||
Window string examples: `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"1d"`.
|
||||
|
||||
Omitting `rateLimit` (or setting it to an empty array) means the use case is not rate-limited. `wireUseCase` still wraps it in `NoopRateLimit` so the slot type is always satisfied.
|
||||
|
||||
---
|
||||
|
||||
## Key-naming convention
|
||||
|
||||
Keys partition the budget across distinct entities. Use the pattern:
|
||||
|
||||
```
|
||||
<feature>:<scope>:<discriminator>
|
||||
```
|
||||
|
||||
| Segment | Example | Meaning |
|
||||
| ----------------- | ------------- | -------------------------------------------- |
|
||||
| `<feature>` | `signIn` | Use-case or feature slug (camelCase) |
|
||||
| `<scope>` | `ip` | Budget name — matches `rateLimit[].name` |
|
||||
| `<discriminator>` | `203.0.113.5` | Per-entity value (IP address, user ID, etc.) |
|
||||
|
||||
Canonical example from `auth/sign-in.use-case.ts`:
|
||||
|
||||
```ts
|
||||
const { allowed: ipAllowed } = await rateLimit.consume(
|
||||
"ip",
|
||||
`signIn:ip:${input.clientIp ?? ""}`,
|
||||
);
|
||||
if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
|
||||
const { allowed: accountAllowed } = await rateLimit.consume(
|
||||
"account",
|
||||
`signIn:account:${input.username}`,
|
||||
);
|
||||
if (!accountAllowed)
|
||||
throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
```
|
||||
|
||||
**Do not** use bare IPs or usernames as keys — include the feature and scope prefix so buckets from different use cases never collide in shared backends.
|
||||
|
||||
---
|
||||
|
||||
## Multi-budget patterns
|
||||
|
||||
### IP + account (credential-stuffing defence)
|
||||
|
||||
Two independent budgets: one throttles by source IP, the other by target account. A single attacker cycling IPs can still be blocked by the account budget; many attackers hitting one account are caught by the per-IP budget.
|
||||
|
||||
```ts
|
||||
// Manifest
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
],
|
||||
```
|
||||
|
||||
```ts
|
||||
// Use case body — check IP first (cheaper lookup)
|
||||
const { allowed: ipOk } = await rateLimit.consume(
|
||||
"ip",
|
||||
`signIn:ip:${input.clientIp ?? ""}`,
|
||||
);
|
||||
if (!ipOk) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
|
||||
const { allowed: accountOk } = await rateLimit.consume(
|
||||
"account",
|
||||
`signIn:account:${input.username}`,
|
||||
);
|
||||
if (!accountOk) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
```
|
||||
|
||||
### Per-user action quota
|
||||
|
||||
One budget limits how many times a single authenticated user can trigger an action per day:
|
||||
|
||||
```ts
|
||||
// Manifest
|
||||
rateLimit: [
|
||||
{ name: "user", window: "1d", budget: 50 },
|
||||
],
|
||||
```
|
||||
|
||||
```ts
|
||||
// Use case body
|
||||
const { allowed } = await rateLimit.consume(
|
||||
"user",
|
||||
`exportReport:user:${input.userId}`,
|
||||
);
|
||||
if (!allowed) throw new TooManyRequestsError("Daily export limit reached");
|
||||
```
|
||||
|
||||
### Weighted consume
|
||||
|
||||
Pass a `weight` argument to consume multiple budget units in one call (e.g., bulk operations):
|
||||
|
||||
```ts
|
||||
// Costs 5 budget units instead of 1
|
||||
const { allowed } = await rateLimit.consume(
|
||||
"user",
|
||||
`sendEmails:user:${input.userId}`,
|
||||
input.recipients.length,
|
||||
);
|
||||
```
|
||||
|
||||
The weight defaults to `1` when omitted.
|
||||
|
||||
---
|
||||
|
||||
## Throwing on rate-limit exceeded
|
||||
|
||||
Throw `TooManyRequestsError` from `@repo/<feature>/entities/errors`. The tRPC error middleware maps this to HTTP 429 via the feature's `xProcedure`:
|
||||
|
||||
```ts
|
||||
import { TooManyRequestsError } from "../../entities/errors/auth";
|
||||
|
||||
if (!allowed) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
```
|
||||
|
||||
Declare `TooManyRequestsError` in the feature's error file and register it in `integrations/api/procedures.ts`:
|
||||
|
||||
```ts
|
||||
// packages/<feature>/src/integrations/api/procedures.ts
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import { TooManyRequestsError } from "../../entities/errors/<feature>";
|
||||
|
||||
export const featureProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
|
||||
// …other error → tRPC code mappings
|
||||
]),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wiring a rate-limit backend
|
||||
|
||||
### Dev / test — `InMemoryRateLimit`
|
||||
|
||||
`InMemoryRateLimit` is a single-process, Map-backed implementation suitable for local development and unit tests. Buckets live only in memory — they reset on process restart.
|
||||
|
||||
```ts
|
||||
import { InMemoryRateLimit } from "@repo/core-shared/rate-limit";
|
||||
|
||||
const rateLimit = new InMemoryRateLimit([
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
]);
|
||||
```
|
||||
|
||||
Pass the same budget declarations as the manifest so dev behaviour matches production.
|
||||
|
||||
### Default (no backend) — `NoopRateLimit`
|
||||
|
||||
`NoopRateLimit` always allows every request (`allowed: true`, `remaining: Infinity`). It is the default when `ctx.rateLimit` is absent, so features boot cleanly without a rate-limit backend wired:
|
||||
|
||||
```ts
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
|
||||
const rateLimit = ctx.rateLimit ?? new NoopRateLimit();
|
||||
```
|
||||
|
||||
Use `NoopRateLimit` in unit tests that exercise the use-case logic but do not need to test throttling behaviour. Use `RecordingRateLimit` from `@repo/core-testing/rate-limit` in tests that must assert on `consume` / `reset` call counts.
|
||||
|
||||
### Production — external backend via `IRateLimit`
|
||||
|
||||
Wire a production backend by implementing `IRateLimit` and passing the instance through `ctx.rateLimit` in the app's `bindAll` aggregator:
|
||||
|
||||
```ts
|
||||
// apps/web-next/src/server/bind-production.ts (excerpt)
|
||||
import { RedisRateLimit } from "@repo/<your-adapter>/rate-limit"; // your implementation
|
||||
|
||||
const rateLimit = new RedisRateLimit(redisClient, {
|
||||
/* budget table loaded from manifest or config */
|
||||
});
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
queue,
|
||||
rateLimit, // passed to all feature binders
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
// …other features
|
||||
```
|
||||
|
||||
Every feature binder receives the same `IRateLimit` instance via `ctx.rateLimit`. Feature binders that declare `rateLimit` budgets in their manifest pass the instance to the use-case factory:
|
||||
|
||||
```ts
|
||||
// packages/auth/src/di/bind-production.ts (excerpt)
|
||||
const wrappedSignIn = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignInUseCase,
|
||||
factory: signInUseCase,
|
||||
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signIn",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit: ctx.rateLimit ?? new NoopRateLimit(), // for brand attachment
|
||||
});
|
||||
```
|
||||
|
||||
`wireUseCase` wraps the factory output with `withRateLimit(rateLimit, fn)` and attaches the `__rateLimited` brand, which `assertFeatureConformance` checks at boot.
|
||||
|
||||
### Environment strategy summary
|
||||
|
||||
| Environment | Recommended backend | How to wire |
|
||||
| ---------------- | ----------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Unit tests | `NoopRateLimit` | Inject directly: `signInUseCase(repo, auth, noop)(…)` |
|
||||
| Rate-limit tests | `RecordingRateLimit` | Inject directly; assert `.calls` |
|
||||
| `pnpm dev` | `NoopRateLimit` | Default in `bindAllDevSeed` via `ctx.rateLimit` |
|
||||
| Staging / prod | `InMemoryRateLimit` or external Redis/Upstash adapter | Set `ctx.rateLimit` in `bindAllProduction` |
|
||||
|
||||
---
|
||||
|
||||
## Conformance gate
|
||||
|
||||
The ESLint rule `conformance/no-undeclared-rate-limit` (warn) fires when:
|
||||
|
||||
- A use-case file calls `rateLimit.consume("X", …)` but `"X"` is not in `feature.manifest.ts` → **undeclared budget**
|
||||
- A manifest entry declares `{ name: "X" }` but the use-case body never calls `rateLimit.consume("X", …)` → **unused declaration**
|
||||
|
||||
Fix by keeping the `rateLimit` array in the manifest in sync with the `rateLimit.consume()` calls in the factory body.
|
||||
|
||||
The boot-time assertion (`assertFeatureConformance`) also requires the `__rateLimited` brand when `rateLimit.length > 0` — the dev server refuses to start if the brand is missing.
|
||||
|
||||
---
|
||||
|
||||
## API surface quick-reference
|
||||
|
||||
| Export | Package path | Purpose |
|
||||
| -------------------- | ------------------------------- | -------------------------------------------------------- |
|
||||
| `IRateLimit` | `@repo/core-shared/rate-limit` | Protocol interface for all backends |
|
||||
| `RateLimitBudget` | `@repo/core-shared/rate-limit` | Manifest budget descriptor `{ name, window, budget }` |
|
||||
| `RateLimitDecision` | `@repo/core-shared/rate-limit` | Result of `consume()`: `{ allowed, remaining, resetAt }` |
|
||||
| `InMemoryRateLimit` | `@repo/core-shared/rate-limit` | Single-process Map-backed implementation |
|
||||
| `NoopRateLimit` | `@repo/core-shared/rate-limit` | Always-allow stub (dev default) |
|
||||
| `withRateLimit` | `@repo/core-shared/rate-limit` | DI wrapper; attaches `__rateLimited` brand |
|
||||
| `RecordingRateLimit` | `@repo/core-testing/rate-limit` | Test helper; records `consume` / `reset` calls |
|
||||
| `RateLimited<F>` | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping |
|
||||
378
docs/guides/security-headers.md
Normal file
378
docs/guides/security-headers.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Security headers cookbook
|
||||
|
||||
Every response from every app emits six security headers and a per-request CSP nonce. This guide walks the wiring for each framework, shows how consumer code threads the nonce into inline scripts, explains CSP allowlist customisation, and covers Sentry nonce integration and verification.
|
||||
|
||||
---
|
||||
|
||||
## The six headers
|
||||
|
||||
`buildSecurityHeaders(opts: SecurityHeadersConfig)` from `@repo/core-shared/security` always emits:
|
||||
|
||||
| Header | Value |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |
|
||||
| `Content-Security-Policy` | mode-dependent (see [CSP modes](#csp-modes) below) |
|
||||
|
||||
`x-nonce` is also forwarded as an internal request header so server components can retrieve the nonce without another round trip.
|
||||
|
||||
---
|
||||
|
||||
## CSP modes
|
||||
|
||||
### `prod` — strict-dynamic + nonce
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'strict-dynamic' 'nonce-{NONCE}';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
|
||||
font-src 'self' {ALLOWED_FONT_ORIGINS};
|
||||
connect-src 'self' {ALLOWED_CONNECT_ORIGINS};
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
```
|
||||
|
||||
`strict-dynamic` allows scripts loaded by a nonce-bearing script to run without listing each origin explicitly.
|
||||
|
||||
### `dev` — permissive for local tooling
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: {ALLOWED_IMG_ORIGINS};
|
||||
font-src 'self' {ALLOWED_FONT_ORIGINS};
|
||||
connect-src 'self' ws: localhost:* 127.0.0.1:* {ALLOWED_CONNECT_ORIGINS};
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
```
|
||||
|
||||
`unsafe-inline` / `unsafe-eval` permit Vite HMR and React DevTools. The `ws:` and `localhost:*` entries allow HMR websockets and local API calls. These never appear in `prod`.
|
||||
|
||||
Both modes are selected automatically from `NODE_ENV`. No manual config is required.
|
||||
|
||||
---
|
||||
|
||||
## Per-framework wiring
|
||||
|
||||
### Next.js (`apps/web-next`)
|
||||
|
||||
Create or update `middleware.ts` at the app root:
|
||||
|
||||
```ts
|
||||
// apps/web-next/middleware.ts
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return withSecurityHeaders(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
`withSecurityHeaders` generates a fresh nonce per request, sets all six headers on the response, and forwards the nonce in both the downstream request headers (`x-nonce`) and the response headers so server components can read it.
|
||||
|
||||
### TanStack Start (`apps/web-tanstack`)
|
||||
|
||||
Security headers are applied via a Nitro/H3 server hook in `app.config.ts`:
|
||||
|
||||
```ts
|
||||
// apps/web-tanstack/app.config.ts
|
||||
import { defineConfig } from "@tanstack/start/config";
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
||||
|
||||
interface H3SecurityEvent {
|
||||
node: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
res: { setHeader: (name: string, value: string) => void };
|
||||
};
|
||||
}
|
||||
|
||||
function applySecurityHeaders(event: H3SecurityEvent): void {
|
||||
const { nonce, headers } = withSecurityHeaders();
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
event.node.res.setHeader(k, v);
|
||||
}
|
||||
event.node.req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
server: { hooks: { request: applySecurityHeaders } },
|
||||
});
|
||||
```
|
||||
|
||||
`withSecurityHeaders()` returns `{ nonce, headers }`. The hook applies the headers to the response and stores the nonce on the request so `getNonce(req)` can read it from any server loader.
|
||||
|
||||
### Payload CMS (`apps/cms`)
|
||||
|
||||
The CMS is server-rendered without client-side JavaScript hydration, so no nonce is needed:
|
||||
|
||||
```ts
|
||||
// apps/cms/middleware.ts
|
||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest): NextResponse {
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({ mode });
|
||||
|
||||
const response = NextResponse.next();
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nonce threading for inline scripts
|
||||
|
||||
### Reading the nonce on the server
|
||||
|
||||
**Next.js** — `getNonce()` reads the `x-nonce` header injected by `withSecurityHeaders`:
|
||||
|
||||
```ts
|
||||
import { getNonce } from "@repo/core-shared/security/next";
|
||||
|
||||
// Inside a Server Component or route handler:
|
||||
const nonce = await getNonce();
|
||||
```
|
||||
|
||||
**TanStack Start** — `getNonce(req)` reads from the H3 request:
|
||||
|
||||
```ts
|
||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||
import { getEvent } from "vinxi/http";
|
||||
|
||||
// Inside a loader:
|
||||
const nonce = getNonce(getEvent().node.req);
|
||||
```
|
||||
|
||||
### Exposing the nonce to the browser
|
||||
|
||||
Expose the nonce via a `<meta>` tag so client-side code can read it without re-fetching:
|
||||
|
||||
**Next.js root layout:**
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { getNonce } from "@repo/core-shared/security/next";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const nonce = await getNonce();
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**TanStack Start root route:**
|
||||
|
||||
```tsx
|
||||
// apps/web-tanstack/src/routes/__root.tsx
|
||||
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => {
|
||||
try {
|
||||
const { getEvent } = await import("vinxi/http");
|
||||
return { nonce: getNonce(getEvent().node.req) };
|
||||
} catch {
|
||||
return { nonce: "" }; // client-side navigation — nonce already in DOM
|
||||
}
|
||||
},
|
||||
component: () => {
|
||||
const { nonce } = Route.useLoaderData();
|
||||
return (
|
||||
<>
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Using the nonce in your inline scripts
|
||||
|
||||
Pass the nonce as the `nonce` attribute on any `<script>` tag you add. In prod mode, `strict-dynamic` propagates trust to scripts loaded by a nonce-bearing script, so third-party scripts loaded dynamically at runtime do not need individual nonces.
|
||||
|
||||
```tsx
|
||||
// In a Server Component (Next.js):
|
||||
const nonce = await getNonce();
|
||||
return (
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: "/* your inline script */" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Do not set `nonce` on scripts that ship as static `*.js` files — the nonce changes per request and will not match cached assets.
|
||||
|
||||
---
|
||||
|
||||
## CSP allowlist customisation
|
||||
|
||||
`buildSecurityHeaders` accepts three optional allowlists. Each entry must be a valid URL string (validated by the `URL` constructor at call time):
|
||||
|
||||
| Option | Default | Controls |
|
||||
| ----------------------- | ------- | ------------------------------------- |
|
||||
| `allowedConnectOrigins` | `[]` | Appended to `connect-src` |
|
||||
| `allowedImgOrigins` | `[]` | Appended to `img-src` (after `data:`) |
|
||||
| `allowedFontOrigins` | `[]` | Appended to `font-src` |
|
||||
|
||||
Example — connect to a remote API and load images from a CDN:
|
||||
|
||||
```ts
|
||||
buildSecurityHeaders({
|
||||
mode: "prod",
|
||||
nonce,
|
||||
allowedConnectOrigins: ["https://api.example.com"],
|
||||
allowedImgOrigins: ["https://cdn.example.com"],
|
||||
});
|
||||
```
|
||||
|
||||
Pass the same options in the framework-level middleware by calling `buildSecurityHeaders` directly instead of `withSecurityHeaders` (the latter calls `buildSecurityHeaders` with no allowlists):
|
||||
|
||||
```ts
|
||||
// apps/web-next/middleware.ts — custom allowlists
|
||||
import {
|
||||
generateNonce,
|
||||
buildSecurityHeaders,
|
||||
} from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = generateNonce();
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({
|
||||
mode,
|
||||
nonce,
|
||||
allowedConnectOrigins: ["https://api.example.com"],
|
||||
allowedImgOrigins: ["https://cdn.example.com"],
|
||||
});
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
response.headers.set("x-nonce", nonce);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sentry nonce integration
|
||||
|
||||
Sentry's browser SDK injects a small inline script during initialisation. In `prod` mode that script is blocked by the nonce-based CSP unless you pass the nonce to `initSentryClientReact` (or the equivalent init function).
|
||||
|
||||
### Reading the nonce on the client
|
||||
|
||||
After the root layout writes `<meta name="csp-nonce">`, client-side code reads it from the DOM:
|
||||
|
||||
```ts
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the nonce to Sentry
|
||||
|
||||
```ts
|
||||
// apps/web-tanstack/src/instrumentation-client.ts
|
||||
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClientReact({
|
||||
dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
|
||||
app: "web-tanstack",
|
||||
release: import.meta.env["VITE_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
```
|
||||
|
||||
The nonce is forwarded to Sentry's `BrowserTracing` integration so Sentry's injected `<script>` elements carry the same nonce as the page and are allowed by the CSP.
|
||||
|
||||
> If Sentry scripts are blocked in prod, open DevTools → Console. The error message will mention a nonce or CSP violation. Check that the `<meta name="csp-nonce">` tag is present in the HTML and that `getNonce()` returns a non-empty string before Sentry initialises.
|
||||
|
||||
---
|
||||
|
||||
## securityheaders.com verification
|
||||
|
||||
1. **Deploy to a staging or production URL** — `securityheaders.com` requires a publicly reachable HTTPS endpoint. Localhost is not supported.
|
||||
2. **Run the scan** — Enter your URL at [https://securityheaders.com](https://securityheaders.com) and click "Scan".
|
||||
3. **Expected grade** — An A or A+ grade with all six headers present and no warnings.
|
||||
4. **Common issues and fixes**:
|
||||
|
||||
| Warning | Cause | Fix |
|
||||
| ---------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `Content-Security-Policy` missing | Middleware matcher excluded the scanned path | Verify `config.matcher` in `middleware.ts` covers the target path |
|
||||
| `unsafe-inline` in prod CSP | `NODE_ENV` not set to `"production"` on the server | Confirm `NODE_ENV=production` in the deployment environment |
|
||||
| Nonce appears as literal `{NONCE}` | `buildSecurityHeaders` called without `nonce` in prod | Ensure the middleware generates a nonce and passes it to the builder |
|
||||
| `connect-src` missing an external origin | API origin not in `allowedConnectOrigins` | Add the origin URL to `allowedConnectOrigins` |
|
||||
| `Permissions-Policy` flagged | Browser support varies; not a blocking issue | No action required — the header is correct |
|
||||
|
||||
5. **Recheck after CSP changes** — each `buildSecurityHeaders` call change should be followed by a re-scan.
|
||||
|
||||
---
|
||||
|
||||
## API surface quick-reference
|
||||
|
||||
| Export | Package path | Purpose |
|
||||
| ------------------------------ | ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `buildSecurityHeaders(opts)` | `@repo/core-shared/security` | Low-level builder; returns `Record<string, string>` |
|
||||
| `generateNonce()` | `@repo/core-shared/security` | 16-byte crypto-random base64 string |
|
||||
| `withSecurityHeaders(request)` | `@repo/core-shared/security/next` | Next.js middleware helper (generates nonce + sets headers) |
|
||||
| `getNonce()` | `@repo/core-shared/security/next` | Read `x-nonce` from Next.js `headers()` |
|
||||
| `withSecurityHeaders()` | `@repo/core-shared/security/tanstack` | TanStack helper; returns `{ nonce, headers }` |
|
||||
| `getNonce(req)` | `@repo/core-shared/security/tanstack` | Read `x-nonce` from an H3 `NodeRequest` |
|
||||
| `SecurityHeadersConfig` | `@repo/core-shared/security` | Config type (`mode`, `nonce?`, `allowed*Origins[]`) |
|
||||
| `InvalidSecurityHeadersConfig` | `@repo/core-shared/security` | Thrown when an origin URL fails `URL` validation |
|
||||
Reference in New Issue
Block a user