DAST scanner · headless browser

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.

DASTHighInjection Riskdom_xss_scannerneeds Playwright

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.

Init script page.add_init_script() runs on every load, before any app JavaScript wraps 12 sinks App code, later el.innerHTML = location.hash.slice(1) the URL fragment flows to a sink Wrapper _record('innerHTML', v) orig.set.call(this, v) records v only if raw markup <i id="DOMXSS_CANARY_…"> arrives The twelve hooked sinks innerHTML · outerHTML · insertAdjacentHTML · document.write · document.writeln · eval · Function setTimeout(string) · setInterval(string) · location.assign · location.replace · window.open window.__domxss_findings, read back by the scanner { sink: "innerHTML", canary: "DOMXSS_CANARY_a1b2c3d4", value: "<img src=x id=\"DOMXSS_CANARY_a1b2c3d4\">", url: location.href } Confirmed DOM XSS · High · 0.95

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.

1 · Query string17 page loads /page?q=<i id=CANARY> · ?query=… · ?search=… q query search s keyword term redirect url next return callback ref page view tab id name one name per load, then wait 1.5 s and read the hooks hit: stop here 2 · URL hash1 page load /page#%3Ci id=CANARY%3E the fragment never leaves the browser: no server log, no WAF sees it runs only if lane 1 had no hit 3 · postMessage4 message shapes "<i id=CANARY>" {"data":"<i id=CANARY>"} {"type":"navigate","url":"…"} {"html":"<img src=x onerror=CANARY>","action":"render"} window.postMessage(msg, '*') after load; runs only if lanes 1 and 2 had no hit 4 · Typed inputup to 8 fields per page type <img src=x id="CANARY"> into each text box, then press Enter for search boxes that render client-side; details in the next section runs only if lanes 1 to 3 had no hit

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.

Inputs found by type text, search, email, number, textarea, contenteditable skipped: hidden, submit, button, checkbox, radio, file, image, range, color, password Typed payload <img src=x id="DOMXSS_CANARY_a1b2c3d4"> no onerror, so it cannot run anything. If it parses, an onerror would too. Observe, after 1.5 s A · a hook recorded the canary at a sink (innerHTML, write, eval…) B · getElementById(canary) exists so the text was parsed as HTML either one is enough Nothing yet? Press Enter many search boxes render on submit Confirmed DOM XSS High · 0.95 · form input sink named from A, or rendered DOM (HTML injection) when only B fired No hit on this box: try the next one, 8 per page at most.

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

Impact

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

Google Search · 2019

A DOM XSS vulnerability where URL fragment input was unsafely injected into the DOM. Google patched it and awarded a bounty.

Salesforce · 2020

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.

Source location.hash.slice(1) BAD · innerHTMLpreview.innerHTML = hash GOOD · textContentpreview.textContent = hash GOOD · React JSX<div>{content}</div> In the DOM parsed as HTML a text node escaped to text The wrapper sees the canary reach innerHTML → Confirmed DOM XSS textContent is not a hooked sink and cannot create elements → nothing to record The typed-input check B also stays false: no element with the canary id appears. The framework escapes the string before it reaches the DOM → no hit at any vector

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)

Limits and configuration

AspectBehaviour
RequiresPlaywright with Chromium. Without it the scanner logs a warning and returns no findings; the rest of the scan continues.
Where it runsCalled 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.
PagesAt most 30 per run. The target URL is tested first so its findings survive a tight budget.
Time300 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 severityHigh, 0.95. Every finding names the sink and the vector.
One hit per pageLater vectors are skipped once one confirms, so a page with several flaws yields one finding.
Hash encodingThe 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 hookedAssigning 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.