Skip to content

Debugging DOM XSS means following data, not guessing payloads

DOM XSS testing is about sources, transformations, and sinks. Payload lists help only after the dataflow is understood.

Published on 2 min read

DOM XSS is where payload spraying becomes especially weak.

The vulnerable line might not be in the server response. It might be in a bundle loaded after hydration, behind a feature flag, triggered by a hash change, or hidden in a component that only renders after an API call. The payload does not matter until you know where the data goes.

Follow the data.

Start with sources

Common sources are predictable:

location.search
location.hash
document.referrer
window.name
localStorage
postMessage

Predictable does not mean easy. A value may enter through location.hash, get parsed into an object, stored in state, passed through a router, then rendered three components later. Modern frontend code is full of indirection.

Search for obvious sinks, but do not stop there.

Sinks are not always named dangerously

The classic sink is innerHTML.

preview.innerHTML = value;

Real code often wraps it:

renderMarkdown(userInput)
setHtml(description)
dangerouslySetInnerHTML={{ __html: html }}
insertAdjacentHTML("beforeend", row)

Sometimes the dangerous operation is inside a dependency. Sometimes the app builds a DOM node safely at first, then later serializes and reparses it. That second parse is where mutation XSS can appear.

Breakpoints beat vibes

Use the browser debugger. Put breakpoints on setters when possible. Monkey patch sinks in a local test session if you need visibility:

const original = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function(position, html) {
  console.trace("insertAdjacentHTML", position, html);
  return original.call(this, position, html);
};

This is ugly. It is also effective.

Once you see the sink, the payload choice becomes much less mysterious. If the value is assigned to textContent, markup payloads are irrelevant. If it reaches innerHTML after one URL decode, encoding strategy matters. If it lands inside an SVG subtree, HTML assumptions may fail.

Watch the transformations in between. A value that starts as %3Cimg%3E may be decoded by URLSearchParams, escaped by a template helper, then decoded again by a Markdown preview. Another value may be lowercased, trimmed, or split on spaces before it reaches the sink. Those steps decide whether a payload needs entity encoding, URL encoding, quote breakout, or a completely different shape.

This is why copying the final working string without the path is weak evidence.

Report the path

A DOM XSS report should include the source, transformations, sink, and trigger. Developers need to reproduce the path without reading your mind.

Bad:

XSS in search page.

Better:

q parameter is read from location.search, decoded by URLSearchParams,
stored in SearchPreview state, and assigned to innerHTML in PreviewCard.

That is the difference between a bug report and a payload screenshot.

Related articles

Responsible XSS testing depends on scope, evidence discipline, data minimization, and knowing when a proof of concept has gone far enough.
A practical look at why XSS tooling should avoid server-side processing when testers are handling internal URLs, private snippets, and sensitive payload notes.
Payload choice is less important than injection context. HTML body, attributes, URLs, scripts, and DOM sinks all fail differently.