Finding 4: the missing CSP header
A launch review opens with one command: curl -I the running app and read the response headers, the cheapest high-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 faces.
The target ships five static security headers and stops one short of Content-Security-Policy, the header that turns an injected script from a full account takeover into a blocked request.
To an experienced engineer, a web surface with no CSP is not “almost there” — it is not launched.
Finding 2 documented the 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 threat model, the missing backstop that lets the script run once it is in.
Your goal: document the absent header as findings/004-csp-header.md, cross-referenced to the sink it would have caught.
One command proves the gap, and it anchors the finished finding’s Location section:
# What headers does the app actually return?curl -sI http://localhost:3000/ | grep -i 'security\|content-security\|frame\|referrer'The response carries Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy — and no Content-Security-Policy line at all.
Those five are what make this “CSP absent,” not “no headers”: someone configured the baseline and stopped one header short.
There is no screenshot, because the defect lives only in the headers and the source — which is exactly why the header read is the audit step.
Your mission
Section titled “Your mission”Document the missing Content-Security-Policy as findings/004-csp-header.md, against the four-section template every finding follows: Rule, Location, Consequence, Fix.
The target ships staticSecurityHeaders in next.config.ts — five headers, no CSP — and src/proxy.ts mints no per-request nonce, so the policy lives in neither file.
That five headers were configured and the sixth was not is itself the tell that this is an omission, not a clean slate.
Run the header pass off the running app first.
curl -I http://localhost:3000/ (or a pass through securityheaders.com) shows the five headers the target ships — HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy — and no Content-Security-Policy.
Then grep both files to confirm the policy is in neither.
The running-app read is the evidence; the source grep is the confirmation.
The trap is stopping at “add a CSP.”
An allow-list of trusted hosts with no per-request nonce is the named anti-pattern: the moment your bundle changes hosts or an attacker finds a script on an allow-listed CDN, the policy is decoration.
A CSP that holds needs a per-request nonce — a fresh token the server mints each request so the browser runs only the scripts it vouched for — plus 'strict-dynamic', which lets those vouched-for scripts load their own chunks without enumerating every host.
Name both in the Fix.
Note the trade-off too: 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 pages.
Read this finding as the second half of finding 2’s threat model.
The CSP complements the sanitized sink, it does not replace it: the sanitizer keeps the payload out, the CSP refuses the script even if the sanitizer fails.
A launch needs both, scored as two findings against one threat model.
You patch nothing here — the Fix you write is a paragraph, not a diff.
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
The rule is from chapter 81, “Headers that block live attacks”: of the six security headers, CSP is the one that blocks live attacks rather than merely hardening posture. Compress it to one sentence and link the lesson rather than re-deriving the six.
## 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 here — the host allow-list with no nonce — pre-empts the most common wrong fix.
Location
Section titled “Location”The CSP belongs in two files and is in neither: the static base in next.config.ts 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 commands answer different questions: curl -I proves the header is absent as the app responds, and rg proves it lives in no file.
# 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; securityheaders.com would dock the grade for the same gap.
Grep 2 returns nothing.
Record the present five as legitimate, or the finding reads as “this app has no security headers,” which is false and discredits the report.
Consequence
Section titled “Consequence”Write it in terms a non-engineer understands, with no “could potentially.” Finding 2’s sink 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 fix is structural, so a short illustrative snippet earns a place — no full diff.
The shape is two halves: 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 snippet has four load-bearing moves:
// 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 static directives could sit in next.config.ts, but script-src carries the request-time nonce, so the script half must be minted in proxy.ts per request — the next.config.ts / proxy.ts split chapter 81 teaches.
The rest of the Fix carries the trade-off and the cross-reference to finding 2:
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.Link finding 2 of this chapter rather than re-documenting the sink.
Severity and the header at the top
Section titled “Severity and the header at the top”Severity is high, not critical: this is a missing defense-in-depth layer behind a live sink, not the sink itself. Finding 2 owns the critical; with both gone the page has no second line.
# 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 assertions check 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, and the Fix naming the per-request nonce and 'strict-dynamic'.
A final probe confirms the seeded defect is still present — next.config.ts ships no CSP key, src/proxy.ts mints no nonce — so a passing gate proves you documented the defect rather than patched 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 reads the shape of your finding, 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.