Skip to content
Chapter 82Lesson 3

Finding 2: the XSS HTML sink

Finding 1 worked a fail-closed bug from the source side with the running app open beside it. Here you turn the same method on a stored cross-site-scripting sink: a place where content one user typed reaches another user’s browser as live HTML. The find is one grep and one glance at a rendered page; the write-up is harder, because the fix is a two-layer threat model, not a single line.

Your goal: catch the unsanitized-user-content sink on the invoice-notes surface and document it as findings/002-xss-html-sink.md.

The tell is already live in the seeded target. Signed in as alice@example.com, open /invoices/00000000-0000-7000-8000-ace000000001. The note’s author typed the literal characters <b>bold</b>. A correct app shows those characters escaped, exactly as typed; this app executed them, painting the word bold as a real bold element. That gap between escaped text and executing markup is the whole fingerprint.

The seeded note at /invoices/00000000-0000-7000-8000-ace000000001, signed in as alice@example.com. The author typed literal bold tags around “bold”, and the page executes them as live markup instead of escaping them.

Surface and document the XSS HTML sink — the user-submitted invoice-note body that renders as raw HTML — as a complete finding. The find is one command: run rg -n "dangerouslySetInnerHTML" src and examine every hit; here there is exactly one. A // biome-ignore directive sits on the sink line. Read it as a tell, not a clearance: the default-on lint rule for dangerous HTML already flagged this line, and the ignore only silences the gate so the target ships green. The tame <b> you saw is the proof of concept; the same path renders <img src=x onerror=…> or a <script> tag with identical trust.

The rule: rendered content is operator-trustworthy or it is not, and user-submitted content never is without sanitization. This is the user-vs-operator split from chapter 080 lesson 2 (“Two audiences, two messages”) meeting the header baseline from chapter 081 lesson 1 (“Security headers”). Cite both by id; do not re-explain them.

The audit is read-only, so the fix is a paragraph, not a diff, and the finding obeys the report template — the four sections (Rule, Location, Consequence, Fix) every file carries. Write the consequence in user-visible terms: name what an attacker does and what the victim sees, with no “could potentially” hedging. Two decisions trip up the write-up. First, sanitizing only on the write path misses rows already in the table — notes written before the sanitizer shipped still carry their raw payload, so the honest fix sanitizes at write and read, stores the sanitized output, and backfills the history once. Second, this sink and the missing CSP (finding 4) are two findings against one threat model — the unguarded sink here, the missing defense-in-depth layer there — and a strict CSP with a nonce does not stop an <img onerror> payload, so each is scored on its own.

Out of scope: the adjacent sink shapes the same eye should recognize — eval, new Function, a string-bodied setTimeout or setInterval, a direct el.innerHTML = assignment. None are seeded here, so recognition is enough. Patching the target is out of scope too, as always on this pass.

The finding file’s four template sections — Rule, Location, Consequence, Fix — are all populated, none left as a bare heading.
tested
The finding names the operator-trustworthiness / sanitization rule and cites its source lessons by id (chapter 080 lesson 2 and chapter 081 lesson 1).
tested
The Location names the grep that surfaced the sink (dangerouslySetInnerHTML across src) and the file plus a line reference it returned.
tested
The Fix names the experienced reach — sanitize at write and read, and store the sanitized output.
tested
The seeded sink is still present in notes.tsx, untouched — the audit is read-only.
tested
The finding is confirmed against the running app: the seeded note’s markup renders as live HTML, not as escaped text.
untested
The Fix explicitly addresses the historical-data vector that a write-only sanitizer leaves open.
untested
The finding records that a strict CSP (finding 4) is the complementary defense-in-depth layer, not a substitute for sanitizing the sink.
untested
A severity is assigned and justified in two lines.
untested
The Location records the biome-ignore directive as a tell that does not retire the finding.
untested

Write findings/002-xss-html-sink.md against the template and the brief — run the grep, confirm the fingerprint on the running app, fill all four sections, and assign a justified severity. Then open the walkthrough below and compare.

Reference solution and walkthrough

The completed finding as it lands in the repo, walked section by section.

The find is one command:

Terminal window
# Every HTML-injection sink in the tree.
rg -n "dangerouslySetInnerHTML" src

It returns exactly one hit, notes.tsx:37. Here is that line in context — the sink the grep pointed at:

<li key={note.id} className="rounded-md border p-3 text-sm">
<div
data-testid="invoice-note-body"
// biome-ignore lint/security/noDangerouslySetInnerHtml: deliberately seeded audit defect #2 (unsanitized user content) — the target ships this bug on purpose; the fix is documented in findings/002-xss-html-sink.md, not applied here.
dangerouslySetInnerHTML={{ __html: note.body }}
/>
</li>

note.body is the free-text column a user typed — invoiceNotes.body in src/db/schema.ts, a plain text() with no transform on the way in, and no sanitizer anywhere on the path from the form to this __html. Record the biome-ignore on line 36 as part of the finding: Biome’s default-on lint/security/noDangerouslySetInnerHtml rule already caught this exact sink, and the directive only silenced it so biome ci — the first gate in pnpm verify — would pass. A suppressed security lint over user input is a tell, not a clearance; it does not retire the bug.

Confirm it on the running app. pnpm db:seed prints the seeded invoice path; navigate to /invoices/00000000-0000-7000-8000-ace000000001 as the seeded admin. The planted note body — Customer asked us to mark this <b>bold</b> — follow up next week. (scripts/seed.ts, SEED_NOTE) — renders the word bold as a live <b> element, not as the escaped text &lt;b&gt;bold&lt;/b&gt;. The markup the user stored is executing in the reader’s page. Swap <b> for <img src=x onerror=…> or <script> and the same path runs it.

Rendered content is operator-trustworthy or it is not, and user-submitted content is never operator-trustworthy without sanitization. This is the user-message-vs-operator-record split (chapter 080, lesson 2) meeting the header baseline (chapter 081, lesson 1, where CSP is the only header that blocks live attacks, XSS first). Together they reduce to one rule: a dangerouslySetInnerHTML sink fed by user input is a defect on its face.

Any user who can write an invoice note can store live HTML, and that HTML runs in the page of anyone who later opens the invoice — across organizations, since the column is shared free text with no per-tenant trust boundary on its content. An attacker plants a note whose body carries a script that reads the victim’s session, exfiltrates the invoice data they can see, fires authenticated mutations as them (changing billing, transferring ownership), or rewrites the page to phish their password. The victim sees an ordinary invoice; nothing on screen warns them. This is stored XSS — the worst shape, because it persists and fires for every reader without the attacker being present.

Name what the attacker does and what the victim sees. “Stored” is the word that earns the severity: a reflected XSS needs the attacker to lure the victim to a crafted URL; a stored one waits in the database and fires for everyone.

Sanitize at write and at read, and store the sanitized output, with DOMPurify as the named tool. The seam is the note’s write path and its render path, not the component’s call site.

// On write (the create-note Server Action): sanitize, store the safe form.
const clean = DOMPurify.sanitize(input.body, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'] });
await tx.insert(invoiceNotes).values({ ...input, body: clean });
// On read (notes.tsx): sanitize again before the sink.
<div data-testid="invoice-note-body" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(note.body) }} />

This is illustrative, not a diff to apply — the audit is read-only. Two decisions carry it.

Write-only is the partial answer the audit must reject, because it leaves the historical-data vector open: every note already in the table was written before the sanitizer existed and ships raw to the reader. The safe posture sanitizes at read too, plus a one-time backfill that rewrites existing rows through DOMPurify. The honest minimum allows no tags — render notes as escaped plain text — widening to an inline allow-list only if the product needs rich notes.

A strict CSP — finding 4, the missing Content-Security-Policy with a per-request nonce and 'strict-dynamic' — is the defense-in-depth layer that would neuter an injected <script> even if a sink slipped through. It is not a substitute for sanitizing this sink: CSP is the backstop, the sanitizer is the gate, and a launch needs both. Each is scored on its own.

Severity: critical — a stored XSS sink on user-controlled content, reachable in every organization’s invoice notes, with no sanitization at the seam and no CSP backstop, so any tenant can plant script that runs in another reader’s authenticated session.

Every clause is load-bearing: stored (persists and fires for every reader), user-controlled (any tenant is an attacker), no backstop (finding 4 means nothing catches it). When all three hold, this is the highest severity on the board.

The sink’s relatives — eval, new Function, a string-bodied setTimeout/setInterval, a direct el.innerHTML = assignment — are not seeded here, so none is a separate finding; the full family lives on the audit checklist in SUMMARY.md.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 3

The Lesson 3 describe block should come back green: the assertions check that all four sections are filled in, the rule named and its lessons cited, the Location pinning the grep and file-and-line, and the Fix naming sanitize-at-write-and-read. A final probe confirms the seeded sink in notes.tsx is untouched.

✓ tests/lessons/Lesson 3.test.ts (8 tests)
✓ Lesson 3 — Finding 002 — the XSS HTML sink
Test Files 1 passed (1)
Tests 8 passed (8)

The tests check that the words are present, not that the finding reasons well. Tick off the parts only you can judge:

You confirmed the fingerprint on the running app — the seeded note renders bold as live markup, not as escaped &lt;b&gt; text.
untested
The Fix names sanitizing at write and read, and calls out the historical rows a write-only fix leaves raw.
untested
The finding cross-references finding 4 (the missing CSP) as the second defense layer, explicitly not a substitute for sanitizing the sink.
untested
The severity is assigned and justified in two lines.
untested
The Location records the biome-ignore directive as a tell that does not retire the finding.
untested
The seeded target still runs unchanged — you documented the defect and edited only findings/002-xss-html-sink.md.
untested