Security Engineering · Web Application Security

DOM XSS: Sources, Sinks, and Why the Server Never Sees the Payload

How client-side flow from attacker-controlled input to an executing sink bypasses server-side encoding, and which of the flow-tracking approaches can follow the value.

Server-side template escaping protects the values the server writes into a document. A substantial class of cross-site scripting never passes through the server at all. The script running in the page reads a value from somewhere the attacker can influence, transforms it, and hands the result to an API that interprets it as code or markup. The bytes that carry the payload are already in the browser; the server’s encoder never sees them.

The vocabulary for this is a dataflow vocabulary. A source is an API that returns attacker-influenced data. A sink is an API that interprets a string as markup, code, or a URL. The vulnerability exists when a path from source to sink lacks a transformation that makes the value inert in the sink’s grammar.

The sources

The obvious sources are the ones that expose the current URL: location.href, location.search, location.hash, document.URL, document.referrer. The fragment is the most convenient for an attacker, because everything after # is never sent to the server — it cannot appear in a server log or be filtered by a server-side rule, and it is not subject to the URL length limits a proxy might impose on the query string.

Beyond the URL, window.name persists across navigations within a tab and is readable by any script that later loads in that tab. postMessage delivers structured data from any origin that holds a reference to the window, and the validation of event.origin is the entire defence. Cookies readable from script, localStorage, and sessionStorage are sources when an application stores user-controlled content in them. Web storage is worth calling out because a value written once by a vulnerable page becomes a persistent source for every later load of that origin.

The less obvious sources are the DOM itself. document.referrer can carry an attacker-authored URL. Text content inserted by the server into the page is not attacker-controlled in the sense that concerns us — it was escaped on the way in — but a script that reads innerText from a region of the page and reinserts it elsewhere has created a flow the server can neither see nor reason about.

The sinks

Sinks divide by the grammar they invoke. The HTML-parsing sinks are innerHTML, outerHTML, insertAdjacentHTML, and document.write; the first three parse their argument as HTML in the context of the element, and document.write parses it as a complete document fragment with the tokenizer state of the point where it was called. The consequence is that <img src=x onerror=...> in an innerHTML assignment creates a live element whose handler fires, because innerHTML does not execute <script> elements but does create and bind everything else.

The code-executing sinks are the string forms of timers — setTimeout and setInterval accept a string and evaluate it — along with the Function constructor and indirect eval. These are direct evaluations; there is no sanitiser that makes arbitrary attacker text safe here, only removal of the sink.

The URL sinks are broader than they look. element.href and element.src with a javascript: scheme execute when the element is activated; setting src on a script element to a data:text/javascript,... URL executes it; srcset has its own parsing rules with commas and descriptors. form.action and button.formAction on a form with a submit event handler are a further case, and the javascript: URL executes in the origin of the page that contains the form.

The document-level sinks that fail more subtly are document.domain assignment, which changes the origin comparison; element.baseURI via a <base> element injected into the document, which changes how every relative URL on the page resolves; and eval-adjacent APIs in frameworks that compile template strings at runtime. None of these executes script directly, and all of them change the meaning of the page in ways an attacker can use.

Why static review misses the flow

The reason DOM XSS resists the server-side review process is that the value is not visible in the response body. A penetration test that replays a payload in a query parameter and greps the raw response finds nothing, because the payload appears only after a script has read the parameter and assigned it to an element. Automated scanners that follow this correctly have to execute the page and instrument the DOM, which is expensive and cannot explore every interaction path.

The corresponding problem for code review is the indirection. A value read in one module, passed through two functions, stored in a closure, and assigned to innerHTML in a fourth is a flow a reviewer has to reconstruct by hand, and the transformations in the middle may be a sanitiser, a truncation, a JSON.parse, or nothing.

Taint tracking and its precision limit

Taint analysis models this directly: mark values from sources, propagate the marks through assignments and operations, and report when a marked value reaches a sink without passing through a sanitiser. The difficulty is that JavaScript has no static types, and the set of operations that propagate or clear a mark is large and case-specific. String concatenation propagates; parseInt clears because the result is a number; a function that returns a value from an object property propagates if the object was tainted; a function whose return value was stored in a variable read from storage propagates in a way that depends on aliasing the analysis must track.

Analyses therefore choose a point on a spectrum. Dynamic taint tracking at runtime is precise about which flows actually occur, but it only sees the inputs it is given and pays a per-operation cost. Static analyses over-approximate — they report flows that cannot happen — and need a sanitiser model that recognises the specific sanitiser in use. Neither substitutes for knowing which sinks exist in the codebase.

The practical bookkeeping is an inventory: every assignment to an HTML-parsing sink, every string-form timer, every URL-valued property. A codebase with a small number of such sites is reviewable; a framework that abstracts them behind a component is better still, because the component can apply the sanitiser in one place.

Sanitising at the sink

Sanitisation for a DOM sink is the same problem as server-side HTML sanitisation, with an additional constraint: it must be a parser-based sanitiser operating on the value immediately before it reaches the sink, and its output must be safe for that sink. A sanitiser that allows <a href> must validate the href’s scheme, or it has produced a javascript: URL sink. That requirement is not satisfiable by an allowlist of element names alone; attribute-level policy is required, and for URL attributes the policy is a scheme allowlist.

The difficulty that follows from DOM semantics is that a sanitiser returning a string was correct only for the parse context it assumed. Assigning the result of an HTML-fragment sanitiser into an HTML context is fine; assigning it into a URL attribute is not, and the type system does not distinguish the two. The robust form is to have the sanitiser return a node tree rather than a string, and to insert nodes, since a tree cannot be reinterpreted by the tokenizer.

Trusted Types, where implemented, encodes exactly this idea. The API makes assignment to a sink accept a TrustedHTML object rather than a string, and the only ways to obtain one are a policy defined by the application or the built-in innerHTML setter being removed. A string assignment to innerHTML then throws rather than executing. The enforcement is at the sink, which is the right place, and it makes the inventory a compile-time concern: every string-to-sink flow has to be named and either routed through a policy or rewritten to use DOM APIs directly.

Framework interpolation and the mismatch

Modern frameworks interpolate text into the DOM through APIs that create text nodes, so a value bound into a component’s template lands as textContent and cannot become markup. The escape hatch is the explicit “raw HTML” binding, which every framework provides because rendering server-supplied markup is a real requirement. The security boundary is therefore not the framework but the set of raw-HTML bindings, and each of them is a sink for the purposes of this analysis.

A common failure is the composition of an escape hatch with a source. A component that renders a search result snippet with highlighting needs to emit <em> around matched terms, so it builds markup on the client from the matched text and the query. If the query string is read from the URL and not re-escaped into the markup, the sink is fed from the URL source through the framework’s own escape hatch. The highlighter is then the vulnerability, and it is invisible in a review of the server routes.

Defence in depth

Content Security Policy limits what a successful DOM injection can achieve by restricting where scripts may load from and whether inline script executes. Its interaction with DOM-based flows is imperfect because the sink still creates elements and still runs handlers, and a policy permitting inline event handlers — or permitting the origin from which a gadget is served — provides a way through. The nonce model and the gadgets that subvert it takes that interaction apart.

The primary control remains source-to-sink discipline: treat every DOM value as a string until it is placed into a node, and place strings only into textContent and into parsed, validated URLs. That rule is enforceable by review, by taint analysis, and by Trusted Types, and it holds regardless of which framework produced the page.

Beyond the sinks, the browser’s own parsing rules decide what a value can become, which is why the tokenizer’s states remain relevant even for values that never touch a server template.

References

  1. DOM-based XSS — OWASP Foundation
  2. HTML Living Standard — Dynamic Markup Insertion — WHATWG
  3. DOMPurify — Cure53
  4. Content Security Policy Level 3 — W3C