DOM XSS
TL;DR: XSS through client-side JS sinks (innerHTML, eval, document.write) rather than the server response.
What it is
Reflected/stored XSS is a server-side bug — server emits attacker input into HTML. DOM XSS is a client-side bug — server’s HTML is fine, but a JavaScript snippet on the page reads attacker-controlled state (URL fragment, postMessage data, localStorage) and writes it into a dangerous sink. The malicious string never appears in the server’s response, so server-side WAFs and grep-the-response scanners miss it.
Preconditions / where it applies
- Page JS reads from a source:
location.hash,location.search,document.referrer,window.name,localStorage,postMessageevent data - Then passes that value (often unsanitised) into a sink:
innerHTML,outerHTML,document.write,eval,setTimeout(string),Function(),.src=of script tag,location=, jQuery$()on a string starting with< - No Trusted Types policy enforces sink safety (see trusted-types-bypass)
Technique
Identify sources and sinks. Burp DOM Invader or Chrome DevTools “Sources → Search” for innerHTML, eval, document.write, location.hash. Trace each sink backward to a source.
1
2
3
4
// vulnerable
document.getElementById('main').innerHTML = decodeURIComponent(location.hash.slice(1));
// payload (in URL fragment — never sent to server)
// https://target/#<img src=x onerror=alert(document.domain)>
Fragment-based payloads bypass server logging entirely (browsers do not send #... to the server). Useful for stealth.
Common patterns:
1
2
3
4
5
6
7
8
// jQuery sink
$(location.hash) // hash="<img src=x onerror=...>"
// template
element.innerHTML = `<a href="${userInput}">` // break out: ">"><img src=x …
// JSON-derived
eval('(' + queryParam + ')') // straight RCE in page context
// postMessage
window.addEventListener('message', e => document.body.innerHTML = e.data)
postMessage chain — see postmessage-bugs. For client-side template engines see client-side-template-injection.
Bypass CSP that allows script-src 'self' by reusing the host’s own JSONP endpoints or trusted-types-less sinks; see content-security-policy-bypass.
Detection and defence
- Static analysis: ESLint with
no-unsanitizedrules, semgrep DOM sink rules - Runtime: enforce Trusted Types (
Content-Security-Policy: require-trusted-types-for 'script') — sinks throw unless input is aTrustedHTML/TrustedScript - Sanitise before writing: DOMPurify on any user-derived string
- Avoid
innerHTMLentirely; prefertextContentand DOM construction - Lock down
postMessagehandlers (origin check + schema)
References
- PortSwigger — DOM-based XSS — sinks and labs
- OWASP — DOM-based XSS Prevention — cheatsheet
- Google — Trusted Types — the structural fix