Finding 4: the CSP header omission
A launch review opens with one command and a stare at what comes back. Before anyone reads a line of your code, they curl -I the running app and read the response headers, because the headers are the cheapest, highest-value signal a target gives off — a single round-trip tells you whether the browser has been told to refuse the attacks every web app is exposed to. And the one header that turns an injected script from a full account takeover into a blocked request is Content-Security-Policy. The target ships five static security headers and stops one short of it. By the bar an experienced engineer brings to a launch, a SaaS surface with no CSP is not “almost there” — it is not launched.
You documented finding 2 two lessons ago: a stored-XSS sink, the unsanitized dangerouslySetInnerHTML on invoice notes that lets an attacker’s <script> into the page. This finding is the other half of that same threat model — the missing backstop that lets the script run once it is in. Your deliverable is findings/004-csp-header.md: the documented absence, the defense-in-depth layer a launch review demands, cross-referenced to the sink it would have caught.
Here is the shape of the deliverable — the running-app read at the heart of the finished finding’s Location section, the one command that proves the header is gone:
# What headers does the app actually return?curl -sI http://localhost:3000/ | grep -i 'security\|content-security\|frame\|referrer'It comes back with Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy — and no Content-Security-Policy line at all. The presence of the other five is what makes this “CSP absent,” not “no headers”: someone configured the baseline and stopped one header short.
There is no browser screenshot for this one. The defect is invisible on the page — it surfaces only in the response headers and the source — which is exactly why the header read is the audit step.
Your mission
Section titled “Your mission”Your job is to document the Content-Security-Policy omission as findings/004-csp-header.md, against the four-section template (Rule, Location, Consequence, Fix) every finding in this audit follows. The target ships staticSecurityHeaders in next.config.ts — five headers, no CSP key — and src/proxy.ts mints no per-request nonce. This is a missing-piece finding, and a missing piece is a real finding, not a “not applicable”: its location is “missing from next.config.ts” and “should be generated in src/proxy.ts,” and the fact that someone configured the other five headers and stopped one short is itself the tell.
Run the header pass the way a launch review runs it: read off the running app first. curl -I http://localhost:3000/ (or a run through securityheaders.com) shows you the five headers the target does ship — HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy — and the absence of any Content-Security-Policy or frame-ancestors. Then grep both files where the policy could live to confirm it is in neither. Record both: the running-app read is the evidence, the source grep is the confirmation.
The trap on this finding is stopping at “add a CSP header.” That is the beginner answer, and it ships a CSP that does nothing — an allow-list of trusted hosts with no per-request nonce is the named anti-pattern, because the moment your own bundle changes hosts or an attacker finds a script on an allow-listed CDN, the policy is decoration. The load-bearing parts of a CSP that actually holds are a per-request nonce — a fresh token the server mints each request so the browser runs only the scripts it vouched for — and 'strict-dynamic', which lets those vouched-for scripts load their own chunks without you enumerating every host. Name those, or the fix is not the fix. And acknowledge the trade-off rather than pretending it away: a marketing page studded with third-party analytics and chat widgets can’t run a strict nonce policy without nonce-ing or hashing each one, so the strict policy is for the authenticated app surface and a looser, documented policy covers the public marketing pages. That is a deliberate per-surface call, not a license to ship nothing.
Read this finding as the second half of finding 2’s threat model. The CSP is the complement to sanitizing the sink, not a substitute for it: the sanitizer is the gate that keeps the payload out, the CSP is the wall behind it that refuses the script even if the gate fails. A launch needs both, and they are scored as two findings against one threat model. You don’t patch anything here — the fix you write is a paragraph, not a diff. The whole audit is a read-only pass; documenting the defect is the deliverable, and fixing it is the next sprint’s work.
findings/004-csp-header.md has all four template sections — Rule, Location, Consequence, Fix — populated.Rule section.Location section records the curl -I evidence and names a file the CSP should live in (next.config.ts and/or src/proxy.ts).Fix section names the per-request nonce and 'strict-dynamic' — the load-bearing parts — not just “add a CSP.”next.config.ts ships no CSP key and src/proxy.ts mints no nonce. You documented the target, you did not patch it.'strict-dynamic' rule.Location records the five static headers the target does ship and the CSP / frame-ancestors it lacks, and names both next.config.ts and src/proxy.ts.Consequence reads as the absence of defense-in-depth behind the finding-2 XSS sink and any future sink, in user-visible terms, with no “could potentially” hedging.Fix names the static CSP base in next.config.ts, the per-request nonce in src/proxy.ts, the x-nonce thread to Server Components, and 'strict-dynamic', with the marketing-site third-party-script trade-off acknowledged.Coding time
Section titled “Coding time”Open findings/004-csp-header.md, work through the template against the brief above, and run the gate before you read the worked solution — the value is in surfacing the missing header yourself, then comparing your call to the reference.
Reference solution and walkthrough
Here is the finding as it lands in the repo, section by section. Read it after your own attempt.
The rule is from chapter 81, “Headers that block live attacks” — the six security headers, where CSP is the one that blocks live attacks rather than merely hardening posture. The whole lesson can be compressed to a single sentence for the finding: the baseline ships a Content-Security-Policy, and the only CSP that holds is a per-request nonce plus 'strict-dynamic'. Link the lesson section rather than re-deriving the six headers here; the finding’s job is to name the rule, not re-teach it.
## Rule
The header baseline ships a Content-Security-Policy, and the only CSP that holds up is a per-request nonce plus `'strict-dynamic'` — a nonce so the browser runs only the scripts the server vouched for this request, and `'strict-dynamic'` so scripts those trust load without re-listing every host (chapter 081, lesson 1 — the six security headers, where CSP is the one that blocks live attacks rather than hardening posture, and the nonce-plus-`'strict-dynamic'` shape is the only one that survives a real app's script graph; an allow-list of hosts without a nonce is the anti-pattern that lesson names).Naming the anti-pattern inside the Rule (the host allow-list with no nonce) is deliberate — it pre-empts the most common wrong fix before the Fix section even arrives.
Location
Section titled “Location”This is the missing-piece reasoning made concrete. The CSP belongs in two files and is in neither: the static base in next.config.ts (the staticSecurityHeaders array and the headers() function), and the per-request nonce half in src/proxy.ts. Name both, with line ranges, and record that the five headers the target does ship are legitimate — that is what makes the finding “CSP absent,” not “no headers at all.”
## Location
A "missing-piece" finding — the CSP belongs in two files and is in neither:
- `next.config.ts` (repo root), `staticSecurityHeaders` (lines 14–26) and the `headers()` function (lines 33–38): ships five static headers — `Strict-Transport-Security`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` — and **no** `Content-Security-Policy`. The static base of a CSP would live here.- `src/proxy.ts` (the whole file, lines 6–26): does a cookie-presence redirect and returns `NextResponse.next()` with no header mutation; it generates **no** per-request nonce and sets **no** CSP. The per-request half of the policy belongs here.
How it surfaced — the header audit is a `curl -I` against the running app, then a grep to confirm where the piece should be and is not:The two-command evidence block carries the discovery: the running-app read, then the source confirmation. Note the two commands answer different questions — curl -I proves the header is absent as the app actually responds (the only proof that survives a launch review), and rg proves it lives in no file you could have missed.
# 1. The running-app fingerprint: what headers does the app actually return?curl -sI http://localhost:3000/ | grep -i 'security\|content-security\|frame\|referrer'# 2. Confirm the absence in source, in both files where a CSP could live.rg -ni 'content-security-policy|nonce|strict-dynamic' next.config.ts src/proxy.tscurl -I returns the five present headers and no Content-Security-Policy line at all; securityheaders.com would dock the grade for the same gap. Grep 2 returns nothing, confirming the policy lives in no file. Recording the present five as legitimate is the part inexperienced auditors skip — without it, the finding reads as “this app has no security headers,” which is false and discredits the report.
Consequence
Section titled “Consequence”The consequence is the absence of a second line of defense, written in terms a non-engineer reading it aloud at the launch review understands. No “could potentially” — the sink in finding 2 is already live in the database, so the failure mode is concrete: a user opening that invoice has the attacker’s script run with their full session authority.
## Consequence
The application has no second line of defense against script injection. CSP is the layer that, even when an XSS sink slips through, stops the injected `<script>` from running because the browser refuses any script the server did not vouch for. With no CSP, a payload that reaches the page executes with the full authority of the user's authenticated session — and finding 2 is exactly such a payload already in the database, a stored note whose body renders as live HTML. A user opening that invoice has the attacker's script run as them: it reads their session, exfiltrates the invoice data they can see, fires authenticated mutations on their behalf, or rewrites the page to phish their password, and nothing on the page or in the browser stops it. This is not a hypothetical future sink — it is the missing backstop behind a live one, so the two findings compound: the sink lets the script in, the absent CSP lets it run.The compounding framing is the point: finding 2 and finding 4 are one threat model split in two, and naming them as compounding in the consequence is what makes the launch reviewer treat the pair with the urgency they deserve.
The fix is structural, so a short illustrative snippet earns its place here — the brief allows one for a structural fix, no full diff. The two-halves shape is the whole idea: a static base in next.config.ts and a per-request nonce in proxy.ts, wired with 'strict-dynamic'.
## Fix
Ship a CSP in two halves — a static base in `next.config.ts` and a per-request nonce in `proxy.ts` — wired with `'strict-dynamic'`. The nonce is the load-bearing part: generate one per request and let the browser trust only scripts carrying it.The illustrative snippet has four load-bearing moves, each worth a focused look:
// proxy.ts — mint a nonce per request, set the policy header, thread it to RSCs.const nonce = Buffer.from(crypto.randomUUID()).toString('base64');const csp = [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'unsafe-inline'`, `object-src 'none'`, `base-uri 'self'`,].join('; ');const requestHeaders = new Headers(request.headers);requestHeaders.set('x-nonce', nonce); // Server Components read this and stamp it on their <script> tags.const response = NextResponse.next({ request: { headers: requestHeaders } });response.headers.set('Content-Security-Policy', csp);A fresh nonce minted per request — Buffer.from(crypto.randomUUID()).toString('base64'). This is the load-bearing part; without it the policy is a host allow-list that an attacker on an allow-listed host walks right through.
// proxy.ts — mint a nonce per request, set the policy header, thread it to RSCs.const nonce = Buffer.from(crypto.randomUUID()).toString('base64');const csp = [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'unsafe-inline'`, `object-src 'none'`, `base-uri 'self'`,].join('; ');const requestHeaders = new Headers(request.headers);requestHeaders.set('x-nonce', nonce); // Server Components read this and stamp it on their <script> tags.const response = NextResponse.next({ request: { headers: requestHeaders } });response.headers.set('Content-Security-Policy', csp);'strict-dynamic' lets a nonce-trusted script load its own dependencies without the policy enumerating every CDN host — the only shape that survives Next.js’s own chunk graph, which loads chunks the config can’t predict.
// proxy.ts — mint a nonce per request, set the policy header, thread it to RSCs.const nonce = Buffer.from(crypto.randomUUID()).toString('base64');const csp = [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'unsafe-inline'`, `object-src 'none'`, `base-uri 'self'`,].join('; ');const requestHeaders = new Headers(request.headers);requestHeaders.set('x-nonce', nonce); // Server Components read this and stamp it on their <script> tags.const response = NextResponse.next({ request: { headers: requestHeaders } });response.headers.set('Content-Security-Policy', csp);The nonce is threaded to Server Components through the x-nonce request header, so each <script> they render carries nonce={...} and the browser admits it.
// proxy.ts — mint a nonce per request, set the policy header, thread it to RSCs.const nonce = Buffer.from(crypto.randomUUID()).toString('base64');const csp = [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, `style-src 'self' 'unsafe-inline'`, `object-src 'none'`, `base-uri 'self'`,].join('; ');const requestHeaders = new Headers(request.headers);requestHeaders.set('x-nonce', nonce); // Server Components read this and stamp it on their <script> tags.const response = NextResponse.next({ request: { headers: requestHeaders } });response.headers.set('Content-Security-Policy', csp);The policy is set on the response per request, because the nonce is per request — this is why the script half can’t live in next.config.ts’s static headers() block alongside the other five.
The split between the two files is not arbitrary: the host-allow-list directives (default-src, object-src 'none', base-uri 'self') are static and could sit in next.config.ts, but the script-src directive carries the nonce and the nonce is request-time, so the script half must be minted in proxy.ts per request. That is the next.config.ts / proxy.ts split chapter 81 teaches — link it rather than re-explaining why one header lives apart from the other five.
The rest of the Fix carries the trade-off and the cross-reference:
The mechanics: a fresh `nonce` per request (`Buffer.from(crypto.randomUUID()).toString('base64')`), threaded to Server Components through the `x-nonce` request header so each `<script>` they render carries `nonce={...}`, and `'strict-dynamic'` so a nonce-trusted script can load its own dependencies without the policy enumerating every CDN host — the only shape that survives Next.js's own chunk graph. The host-allow-list parts (`default-src`, `object-src 'none'`, `base-uri 'self'`) form the static base; the script policy is request-time because the nonce is. The trade-off to acknowledge: a marketing site that injects third-party scripts (analytics, chat widgets) cannot use a strict nonce policy without nonce-ing or hashing each one, so the strict policy is for the authenticated app surface and a looser, documented policy covers any third-party-heavy public page — that is a deliberate per-surface decision, not a reason to ship no CSP.
This is the defense-in-depth backstop for **finding 2** (the unsanitized `dangerouslySetInnerHTML` XSS sink on invoice notes): a strict nonce CSP would refuse the injected `<script>` even if the sanitizer were missing. It is the complement, **not** the substitute — the two are one threat model split into the sink and its missing backstop, and a launch needs both. Sanitizing the sink (finding 2) is the gate; the CSP here is the wall behind it. Neither retires the other; each is scored on its own.The XSS sink this header backstops is documented in finding 2 of this chapter — cross-reference it, don’t re-document the sink.
Severity and the header at the top
Section titled “Severity and the header at the top”Severity is high, not critical, and the two-line justification carries why: this is a missing defense-in-depth layer behind a live sink, not the sink itself — finding 2 owns the critical, but with both gone the page has no second line. That distinction is the judgment call worth making explicitly. A missing backstop is grave; it is not the same grave as the open door it backstops.
# Finding 004 — No Content-Security-Policy header: the XSS backstop is absent
**Category:** Security headers (security baseline).**Severity:** high — the one header that turns an XSS sink from a full compromise into a blocked attack is missing site-wide, and finding 2 is a live stored-XSS sink that this header would have neutered; it is high rather than critical because it is a missing defense-in-depth layer, not the sink itself (finding 2 owns the critical), but with both gone the page has no second line.The exact proxy.ts nonce + 'strict-dynamic' + x-nonce-to-RSC pattern your Fix section names.
Canonical reference for nonce and 'strict-dynamic' semantics — what each directive actually enforces.
Google's tool that grades a policy and flags the host-allow-list-without-nonce anti-pattern the Rule warns against.
The directive your Location flags as absent — what it controls and how it differs from X-Frame-Options.
Moment of truth
Section titled “Moment of truth”Run the gate:
pnpm test:lesson 5The gate asserts the observable shape of what you wrote — all four template sections populated, the CSP rule named and the chapter 081 source cited, the Location carrying the curl -I evidence and naming a host file, the Fix naming the per-request nonce and 'strict-dynamic' — plus a source-shape probe that the seeded defect is still present: next.config.ts ships no CSP key and src/proxy.ts mints no nonce. That last probe is the point of the read-only discipline — a passing gate proves you documented the defect rather than patching the target.
Expected output on success — all suites green:
✓ Req 1 — all four template sections are populated (5) ✓ Req 2 — the Rule names the CSP / security-headers rule (2) ✓ Req 3 — the Location records curl evidence and names a host file (2) ✓ Req 4 — the Fix names the senior reach, not just "add a CSP" (2) ✓ Req 5 — the seeded defect is still present (target documented, not patched) (2)
Test Files 1 passed (1)The gate can read the shape of your finding but not its judgment. Confirm the rest by hand:
Location records the running-app curl -I response — the five headers present and the CSP / frame-ancestors absent — and names both next.config.ts and src/proxy.ts, not just one.Fix names the per-request nonce and 'strict-dynamic', not just “add a CSP header,” and threads x-nonce to Server Components.Consequence reads in user-visible terms with no “could potentially” hedging, and the severity justification holds up when you read it aloud.A passing gate plus these five ticks means finding 4 is done. The header pass is also the fastest sharpening for your own audit reflex: every launch review you run from here starts with curl -I before it starts reading code.