Skip to content
Chapter 82Lesson 3

Finding 2: the XSS HTML sink

You wrote your first finding in the previous lesson by working a fail-closed bug from the source side, with the running app open beside it. This lesson keeps the same method and turns it on the most dangerous class of bug a SaaS can ship: a place where content a user typed reaches another user’s browser as live HTML. The whole find is one grep and one glance at a rendered page — and the write-up is where the experience shows, because the fix is not a single line but a two-layer threat model.

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

Here is the tell, already live in the seeded target. Open /invoices/00000000-0000-7000-8000-ace000000001 signed in as alice@example.com, and the planted note paints the word bold as a real bold element:

The seeded note at /invoices/00000000-0000-7000-8000-ace000000001. The author typed the literal bold tags around “bold”, and the page is executing them as live markup instead of escaping them — the visible fingerprint of the XSS sink.

The note’s author typed the literal characters <b>bold</b>. A correct app would show you those characters, escaped, exactly as typed. This app ran them. That is the entire fingerprint of a stored cross-site-scripting sink, and once your eye knows the difference between escaped text and executing markup you will catch this class of bug in seconds. The deliverable is the written finding, not a fix — you document the defect and leave the target untouched.

Surface and document the XSS HTML sink — the user-submitted invoice-note body that renders as raw HTML — as a complete finding in the report. The find is one command. Run rg -n "dangerouslySetInnerHTML" src and examine every hit; here there is exactly one. You will notice a // biome-ignore directive sitting on the sink line. Read that as a tell, not a clearance: the lint rule that flags dangerous HTML is on by default, it already flagged this exact line, and the ignore only silences the gate so the seeded target can ship green. A directive that suppresses a security lint over user input is evidence the bug is real, not evidence it was handled. Then confirm it on the running app. pnpm db:seed prints the invoice path; opening it renders the planted <b>bold</b> as live markup. The tame <b> is the proof of concept — the same code path renders <img src=x onerror=…> or a <script> tag with identical trust.

The rule you are naming is the one the platform teaches twice. Rendered content is operator-trustworthy or it is not, and user-submitted content is never operator-trustworthy without sanitization — the user-vs-operator message split from chapter 080 lesson 2 (“Two audiences, two messages”) meets the header baseline from chapter 081 lesson 1 (“Security headers”), where a strict Content-Security-Policy is the one header that blocks live attacks. Your finding cites both by id; it does not re-explain them.

This is a read-only audit, so the fix you write is a paragraph, not a diff, and the finding must obey the report template — the same four sections (Rule, Location, Consequence, Fix) the contract demands of every file. Write the consequence in user-visible terms: name what an attacker does and what the victim sees, with no “could potentially” hedging. Two decisions are where an inexperienced engineer gets this wrong, and your write-up has to pre-empt both. The first is sanitizing only on the write path. That feels complete, but it misses the rows already in the table — every note written before the sanitizer shipped still carries its raw payload and renders straight to the reader, so the honest fix sanitizes at write and at read and stores the sanitized output, with a one-time backfill for the history. The second is treating this sink and the missing CSP (finding 4) as one issue. They are two findings against a single threat model — the unguarded sink here, and the missing defense-in-depth layer there — and a strict CSP with a nonce does not save you from an <img onerror> payload. Each is scored on its own.

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

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

Here is 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. There is no sanitizer anywhere on the path from the form to this __html. The biome-ignore on line 36 is the second signal worth recording: 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. The lesson here is to record the ignore as part of the finding: a suppressed security lint over user input is a tell, not a clearance, and it does not retire the bug.

The running-app confirmation is the part you do with your eyes. 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. <b> is the tame proof; swap it 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.

One sentence, both lessons cited by id, no re-teaching. The two ideas you learned separately combine into a single non-negotiable here.

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.

User-visible terms, no hedging. Note the framing: 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. So the only safe posture sanitizes at read as well, plus a one-time backfill that rewrites existing rows through DOMPurify. And the honest minimum is to allow no tags at all — render notes as escaped plain text — and widen to an inline allow-list only if the product genuinely needs rich notes.

A strict CSP — finding 4, the missing Content-Security-Policy with a per-request nonce and 'strict-dynamic' — is the complementary 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 a backstop, the sanitizer is the gate, and a launch needs both. (CSP as a defense-in-depth layer is covered in chapter 081 lesson 1; this finding only points at it.) The two findings are one threat model split into the sink here and its missing backstop in finding 4, each 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.

Two lines, and 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.

A last note on what not to write here. Once you have found one HTML sink, the same eye sweeps for its relatives — eval, new Function, a string-bodied setTimeout/setInterval, a direct el.innerHTML = assignment. None are seeded in this target, so none is a separate finding; the recognition is the skill, and 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 finding-shape assertions (all four sections populated, the rule named and its lessons cited, the Location naming the grep and the file plus a line, the Fix naming sanitize-at-write-and-read with the sanitized output stored) and the source-shape probe that 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