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 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.
Your mission
Section titled “Your mission”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.
dangerouslySetInnerHTML across src) and the file plus a line reference it returned.notes.tsx, untouched — the audit is read-only.biome-ignore directive as a tell that does not retire the finding.Coding time
Section titled “Coding time”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.
Location
Section titled “Location”The find is one command:
# Every HTML-injection sink in the tree.rg -n "dangerouslySetInnerHTML" srcIt 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 <b>bold</b>. 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
dangerouslySetInnerHTMLsink 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.
Consequence
Section titled “Consequence”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
DOMPurifyas 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
Section titled “Severity”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.
The canonical reference for the threat model and the stored-vs-reflected distinction your consequence and severity lean on.
Output encoding, HTML sanitization, and CSP-as-backstop — the layered defense your Fix section names.
The sanitizer your Fix calls out by name; the README warns against sanitizing then mutating afterward.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3The 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:
<b> text.biome-ignore directive as a tell that does not retire the finding.findings/002-xss-html-sink.md.