DOM XSS in a real browser
DOM XSS never shows up in the HTML the server sends, so a request/response scanner cannot see it. This scanner runs the real app in headless Chromium, wraps twelve dangerous browser functions before the app's JavaScript starts, feeds a canary in through four doors, and reports a finding only when the canary arrives at one of those functions.
Hooking the sinks before the app runs
A sink is a browser API that turns a string into markup or code. The scanner replaces each one with a thin wrapper that notes whether the string holds the scanner's canary markup, then calls the original. The app behaves normally and the wrapper watches.
1The scanner starts headless Chromium (or reuses the authenticated crawler's page) and registers an init script. Pages are loaded with the session's auth headers when credentials exist.
2The init script runs before any app JavaScript on every navigation. It replaces innerHTML, outerHTML, insertAdjacentHTML, document.write, document.writeln, eval, Function, the string forms of setTimeout and setInterval, location.assign, location.replace and window.open with wrappers.
3The app's own code later assigns something derived from the URL to innerHTML. It does not know it is being watched.
4The wrapper records a call only if the scanner's canary markup (<i id="DOMXSS_CANARY_…">) arrives raw. A string that was HTML-escaped or URL-encoded on the way still contains the bare canary token but is not recorded. The wrapper then runs the original function unchanged. Ordinary app strings never match, so the page works and there is nothing to false-positive on.
5After the page settles the scanner reads the recorded hits. A hit becomes "Confirmed DOM XSS: canary reached the innerHTML sink", High, 0.95, with the sink name and the vector that carried the canary.
Four ways in, tried in order
Each page gets its own canary, DOMXSS_CANARY_ plus 8 hex characters. The scanner tries the doors cheapest first and stops at the first confirmed hit on that page.
1Query string. The page is loaded once per name in a list of 17 common parameter names, with the canary markup <i id="CANARY"> as the value. After each load the scanner waits 1.5 seconds and reads the sink hooks. The first name that produces a hit ends the lane.
2Hash fragment. The canary markup goes after the #. Browsers do not send the fragment to the server, so this finds flows that no server-side filter or WAF can see.
3postMessage. The page loads, then the scanner posts four message shapes to it: a bare string, a data object, a navigate command, and an html render request. This finds message handlers that trust any origin.
4Typed input. For flows that need a person to type, the scanner fills real text boxes and observes. The next scene shows how.
5First hit wins per page. A later lane runs only if every earlier lane found nothing. Across the site the scanner visits at most 30 pages, target URL first, and returns what it has when its 300 second budget runs out.
Typing into inputs: two ways to confirm
Some pages only render the user's text after it is typed and submitted, such as an SPA search box. The scanner finds those boxes from the live page by their type, with no app-specific selectors, and checks two independent oracles.
1The scanner asks the live page for every text-bearing input and skips the ones where typing markup is noise: hidden fields, buttons, checkboxes, radios, files, sliders, colour pickers and passwords. It uses at most 8 per page.
2The payload is an <img> whose id is the canary. It has a broken src and no onerror, so it is harmless. If the page parses it as an element, the same page would parse an onerror version. Boxes that cannot be filled (rich editors) are clicked and typed into instead.
3After 1.5 seconds the scanner checks two things. Either the hooks saw the canary at a sink, or an element with the canary id now exists in the DOM. Both mean the typed text was treated as markup.
4If neither happened, it presses Enter in the same box and checks again, because many search boxes only render after submit.
5A hit ends the page and is reported with source "an interactive form input". If only the DOM check fired, the sink is named "rendered DOM (HTML injection)". No hit moves on to the next input.
Why it matters
DOM XSS is harder to see than reflected XSS because the payload never touches the server's output:
- WAFs cannot block it. The payload can sit in the URL fragment, which is not sent to the server.
- Server-side sanitization does not help. The flaw is in client-side code.
- The impact is the same as reflected XSS. Token theft, session hijacking, defacement.
Modern single-page apps (React, Next.js, Vue) read URL parameters and render them dynamically, which makes them especially exposed.
Real-world breaches
A DOM XSS vulnerability where URL fragment input was unsafely injected into the DOM. Google patched it and awarded a bounty.
DOM XSS in the Lightning component framework where URL parameters were reflected into the page DOM without sanitization.
The fix: pick a safe sink
The same URL fragment is harmless if it lands in an API that treats it as text. The hooks never fire for a safe sink, so the scanner has nothing to confirm.
1The vulnerable line. A fragment reaches innerHTML, the init script's wrapper records it, and the scanner reports it.
2Use textContent. It writes text only, so there is no markup to parse.
3In React, render the value as a JSX child. It is escaped for you.
Vulnerable vs fixed
// BAD: innerHTML with user input
document.getElementById("preview").innerHTML = location.hash.slice(1)// GOOD: Use textContent instead
document.getElementById("preview").textContent = location.hash.slice(1)
// GOOD: Use React's built-in escaping
function Preview() {
const content = window.location.hash.slice(1)
return <div>{content}</div> // React auto-escapes
}Limits and configuration
| Aspect | Behaviour |
|---|---|
| Requires | Playwright with Chromium. Without it the scanner logs a warning and returns no findings; the rest of the scan continues. |
| Where it runs | Called by the agent during the authenticated crawl, reusing the crawler's browser page, or standalone with auth headers set on a new context. It is not a normal endpoint scanner. |
| Pages | At most 30 per run. The target URL is tested first so its findings survive a tight budget. |
| Time | 300 s soft budget so the scan returns what it found rather than being cancelled. Per page: 15 s navigation timeout, 8 s network-idle wait, 1.5 s wait after each injection. |
| Confidence and severity | High, 0.95. Every finding names the sink and the vector. |
| One hit per page | Later vectors are skipped once one confirms, so a page with several flaws yields one finding. |
| Hash encoding | The hash vector sends the markup percent-encoded (#%3Ci%20id%3D…). An app that reads location.hash and inserts it without decodeURIComponent receives encoded text and is not flagged. |
| Not hooked | Assigning location.href directly (it cannot be wrapped), and sinks outside the twelve listed. location.assign and location.replace are in the list, but the hook installs inside a try/catch because Chromium may ignore or reject the override, so in practice about ten are active. A call is recorded only when markup with an id of DOMXSS_CANARY_ plus 8 hex characters reaches the sink raw. |