XSS and Content Security Policy
Untrusted data becomes code when it reaches a sink in your origin; encode for the context, sanitize before the sink, nonce-lock scripts with CSP.
Predict first
Before you read: predict
ExamHall lets question authors write rich-text questions. One author saves a question whose body is an image tag with an onerror handler (a harmless marker that sets window.__xss = 1). The candidate exam page renders each stored author body by assigning it to a container's innerHTML. There is no output encoding, no sanitizer and no CSP.
When a candidate opens that question, what happens to the author's onerror code?
The 30-second version
XSS is attacker data run as code in your page's origin. It arrives reflected from the server, stored then served to others, or written by client code into a DOM sink like innerHTML. Fix it with output encoding matched to the context, or sanitize before the sink. React escapes text, not its hatches. A nonce CSP limits the rest.
Untrusted data becomes code when it reaches a sink in your origin; encode for the context, sanitize before the sink, nonce-lock scripts with CSP.
How it works
Step 1 of 9
In the kitchen
- a note handed to the band as the running order, and obeyed → data reaching a sink and running as code (steps 1, 5)
Analogy: A masquerade ball with a doorman and a bandmaster
A gate-crasher slips a note to the staff. If the announcer reads it aloud as a quotation, it is only words. If a footman hands it to the band as tonight's running order, the band obeys it. The doorman lets a guest onto the floor only if they wear tonight's freshly cut wristband — never because a name sits on a standing-invitation list, which anyone can forge.
| At the ball | In the browser |
|---|---|
| a note read aloud as a quotation | data encoded for its context: inert (steps 5–6) |
| a note handed to the band as the running order, and obeyed | data reaching a sink and running as code (steps 1, 5) |
| a note slipped back to the sender / pinned to the board / picked up by the band itself | reflected / stored / DOM delivery (steps 2–4) |
| tonight's freshly cut numbered wristband | a per-response CSP nonce (step 9) |
| a standing-invitation list anyone can forge | a host allow-list, bypassable (step 9) |
| the bandmaster refusing any note not copied onto an official card | Trusted Types guarding DOM sinks (step 9) |
In real terms: the same author string is safe as a quotation and dangerous as an instruction; which one it becomes is decided entirely by where your code puts it.
Breaks down when: "we sanitized the input, so we are safe." Encoding must match the output context, and a fragment-read value never reaches the server's input filter at all (step 4).
Contrasting case: the same-origin policy and CORS (the prereq) govern reading other origins' responses. XSS runs inside your own origin, where those rules never apply.
Examples, run for real
Every marker below fired, or stayed inert, in Playwright's Chromium against a local vulnerable page. window.__xss = 1 is the whole payload.
4.1 EXH-1400: the author marker reaching the candidate page three ways
One question author's stored rich-text body (an <img onerror> marker) ran inside every candidate's exam page. The same marker is shown arriving reflected, stored and via the DOM, then stopped by encoding for the output context (MR !1600).
vulnerable:
reflected (server echoes ?q) marker FIRED
stored (served author body) marker FIRED
DOM (client innerHTML of hash) marker FIRED
after the fix — MR !1600 (encode for the HTML context on OUTPUT; safe DOM sink):
reflected + server escapeHtml marker inert | shows literal: for <img src="does-not-exist"
stored + server escapeHtml marker inert | shows literal: <img src="does-not-exist" oner
DOM + textContent sink marker inert | shows literal: <img src="does-not-exist" oner
The reflected and stored holes are closed by HTML-escaping on output (steps 2–3, 5–6). The DOM hole is not: the value never reaches the server, so the fix is a safe client sink — textContent instead of innerHTML (step 4).
4.2 React escaping and its escape hatches
Observed from the pinned react-dom/server, then mounted in Chromium. React escapes a text child and an attribute; the escape hatches pass raw HTML through; React 19 rewrites a javascript: URL to a throw.
React 19.3.0 — renderToStaticMarkup output (observed):
text child {author} -> <div><img src="does-not-exist" onerror="window.__xss=1"></div>
attribute title={author} -> <div title="<img src="does-not-exist" onerror="window.__xss=1">">hi</div>
dangerouslySetInnerHTML -> <div><img src="does-not-exist" onerror="window.__xss=1"></div>
<a href={javascript:...}> -> <a href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')">continue</a>
mounted in Chromium:
escaped text child marker inert | inert text, no element
dangerouslySetInnerHTML marker FIRED | raw HTML → sink runs
javascript: href, clicked marker inert | URL scheme is a sink
Text and attribute values are escaped, so {userData} is inert (step 7). dangerouslySetInnerHTML is the real hole — raw HTML reaches the sink and runs (step 8). React 19 additionally blocks javascript: URLs in href, so that one hatch is closed in React; the raw DOM has no such guard (step 7).
4.3 CSP: the same injected marker under each policy
The identical injected marker is served into the page under six policies; Chromium enforces each. A per-response nonce blocks it; a host allow-list that trusts a reflecting host does not.
1. no CSP marker FIRED
2. host allow-list incl. the CDN marker FIRED (allow-listing a reflecting host is bypassable)
3. per-response nonce vs CDN script marker inert | violation script-src-elem:http://127.0.0.1:PORT/jsonp?cb=window.__xss%3D1
3b. per-response nonce vs inline handler marker inert | violation script-src-attr:inline
4. 'strict-dynamic': injected CDN script marker inert | violation script-src-elem:http://127.0.0.1:PORT/jsonp?cb=window.__xss%3D1 | app's own dynamic chunk ran: yes
5. Trusted Types (require-trusted-types-for) marker inert | violation require-trusted-types-for:trusted-types-sink | trustedTypes supported: object
6. hash-source (static inline app script) marker inert | app inline script ran: yes | 'unsafe-inline' ignored beside a hash
A host allow-list is not a boundary: the allowed host reflected the cb param into runnable JS (row 2). A per-response nonce blocks both an injected script (script-src-elem) and an injected inline handler (script-src-attr), reporting each (rows 3, 3b). strict-dynamic lets the app's own nonced code load a chunk while the injected script stays blocked, and host allow-list entries are ignored (row 4). Trusted Types refuses the DOM sink itself, reporting require-trusted-types-for (row 5). For a static or cached page with no per-response nonce, a script's hash allow-lists that inline script while the injected handler is blocked; 'unsafe-inline' beside a hash is ignored (row 6). CSP does not fix the injection; it limits what an injected script can do (step 9).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A chat widget appends each new message by calling messageList.insertAdjacentHTML('beforeend', msg). One message is the harmless <img onerror> marker string.
Does the marker run?
Question 2 of 5
A shop builds a plain server-rendered <a> "continue" link (not React) whose href comes from a user value. The team HTML-escaped that value to be safe. The value is an attacker's javascript: URL.
On click, does the marker run?
Question 3 of 5
A checkout page, built fresh per response, has an injected inline onerror handler in its markup and its own inline app script.
Which script-src stops the injected handler and still lets the app's own script run?
Question 4 of 5
A box-office page shows seat-hold notes written by other users. It renders each note by assigning it to innerHTML after removing only <script> tags. One note has a benign <b> label and an <a> link, plus an <img onerror> marker — and the marker runs. The goal is a render where the marker stays inert and the note's <b> and link still show.
Which is the bug?
Question 5 of 5
A video site serves a help page from a CDN cache — the same bytes to everyone, so no per-response value can be minted. The page has its own inline widget script, and an attacker has injected an inline onerror marker into the cached HTML that now runs. The goal: the injected handler stays inert and the page's own inline script still runs.
Which script-src reaches it?
Go deeper
Pro tips
- Turn on Trusted Types to kill DOM-sink XSS at the sink.
Content-Security-Policy: require-trusted-types-for 'script'makes a string assignment toinnerHTMLthrow unless it passed a policy. When: apps with client-side sinks (step 9). Verify: the marker stays inert and Chromium reportsrequire-trusted-types-for:trusted-types-sink;typeof window.trustedTypesisobject. Trusted Types is now Baseline 2026 — Newly available (MDN, re-fetched 2026-09-28): Chrome 83, Firefox 148, Safari 26. This lesson ran it in Chromium only, so the observation is Chromium's; the cross-browser support is MDN's. It is still not graded as a universal rule. - Prefer a per-response nonce (or a static script's hash) with
strict-dynamicover allow-listing host names. web.dev's strict-CSP guidance says host allow-lists "can be bypassed in most configurations", and CSP Level 3 defines the nonce, hash and'strict-dynamic'sources. When: any CSP you write (step 9).
Source
- OWASP Cross Site Scripting Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html · fetched 2026-09-28. Output encoding per context (HTML, attribute, URL, JavaScript, CSS): "start with your framework's default output encoding protection" and "perform output encoding as close to where the data is rendered"; where that is not enough, "HTML Sanitization should be used". Supports steps 1, 5, 6, 8. Valid as of 2026-09-28.
- OWASP DOM based XSS Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html · fetched 2026-09-28. Untrusted data flowing into DOM sinks (
innerHTML,document.write,eval); server-side controls cannot see DOM-only data. Supports steps 4, 5. Valid as of 2026-09-28. - web.dev, Mitigate cross-site scripting (XSS) with a strict Content Security Policy: https://web.dev/articles/strict-csp · fetched 2026-09-28. Nonce- or hash-based strict CSP; host allow-lists "can be bypassed in most configurations"; "Use a hash-based CSP for HTML pages served statically, or pages that need to be cached";
strict-dynamicto avoid allow-listing hosts. Valid as of 2026-09-28. - W3C, Content Security Policy Level 3: https://www.w3.org/TR/CSP3/ · fetched 2026-09-28.
script-src, nonces (nonce-source), hashes, and the'strict-dynamic'source expression and its propagation to dynamically inserted scripts. Valid as of 2026-09-28. - MDN, Content Security Policy (CSP): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP · and Trusted Types API: https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API · re-fetched 2026-09-28. CSP as a delivered response header the browser enforces;
require-trusted-types-forandtrusted-types. The Trusted Types API page reads "Baseline 2026 — Newly available" ("Since February 2026, this feature works across the latest devices and browser versions"); MDN browser-compat-data gives Chrome 83, Firefox 148, Safari 26 forTrustedTypePolicyandrequire-trusted-types-for. Valid as of 2026-09-28. - React,
dangerouslySetInnerHTML: https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html · fetched 2026-09-28. React escapes rendered values by default;dangerouslySetInnerHTMLbypasses that. Supports steps 7–8. Valid as of 2026-09-28. - Cross-pack:
net-cookies-samesiteownsHttpOnly(which limits what stolen XSS can read) and cookie attributes;sec-csrf-samesiteowns CSRF;net-same-origin-corsowns the same-origin policy and CORS, which XSS runs inside and so never triggers. This lesson links them and does not re-teach them.
Related lessons
Checked against the tools and docs as of 2026-09-28.