What it is
A DOM-based vulnerability is when the page's own JavaScript takes data from an attacker-controlled source (the URL, location.hash, postMessage, localStorage) and passes it to a dangerous sink (innerHTML, eval, location) — all in the browser, never touching the server.
Why you should care
What makes DOM-based different from every other vulnerability in the catalogue: the server never sees the payload, so every server-side defence is blind to it.
- The WAF does not see it.
location.hash(the part after the#) is not sent to the server — the browser keeps it. A payload in the fragment passes every WAF, every log, every server-side check. - Server-side tests cannot catch it. No request contains the payload for an integration test to see. It needs a real browser to reproduce.
- The framework does not handle all of it. React escapes the HTML body, but
dangerouslySetInnerHTML,location = userUrl, andevalare still dangerous sinks the framework does not block.
This is a form of XSS (see the xss topic), but different enough to stand alone: reflected/stored XSS passes through the server and can be fixed there; DOM-based can only be fixed client-side, and you find it by tracing source → sink in JavaScript, not by scanning requests.
How the attack works
The mechanism is a source → sink flow entirely in the browser. Knowing the source list and the sink list is how you hunt it systematically.
flowchart LR subgraph SRC["Sources (attacker-controlled)"] S1["location.hash / .search"] S2["postMessage"] S3["localStorage / document.referrer"] end subgraph SINK["Sinks (execution)"] K1["innerHTML / outerHTML"] K2["eval / Function / setTimeout(str)"] K3["location / location.href"] end S1 --> K1 & K2 & K3 S2 --> K1 S3 --> K1 K1 --> X["XSS — script runs in the origin"] K2 --> X K3 --> O["Open redirect / javascript: URL"]The key point: the server is not in the diagram. The whole source-to-sink flow happens in JavaScript on the victim's machine, so it is a client-code bug, findable only by reading the client code or using a real browser.
A source-and-sink table — the map for hunting:
| Source (untrusted) | Sink (dangerous) | Consequence |
|---|---|---|
location.hash, .search | innerHTML | XSS |
postMessage (no origin check) | innerHTML, eval | XSS from another frame |
location.hash | location = ... | open redirect, javascript: URL |
document.referrer | innerHTML | XSS via the referring page |
| any source | eval, Function, setTimeout("str") | direct code execution |
postMessage deserves its own note: a message handler that does not check event.origin accepts data from ANY page that can open a frame to yours — so an attacker page frames yours and posts a payload. This is a common DOM XSS that few people hunt for.
Diagram description: A source-sink diagram for DOM XSS, with no server in it. On the left are attacker-controlled sources: location.hash and location.search, postMessage, localStorage and document.referrer. On the right are dangerous sinks: innerHTML and outerHTML, eval and Function and setTimeout with a string, location and location.href. Data flows from source to sink: into innerHTML or eval it becomes XSS running in the origin, into location it becomes an open redirect or a javascript URL. The whole flow lives in the victim browser so the server never sees the payload.
Concrete example
A "share" widget reads the page title from the fragment to show a heading — and the fragment never reaches the server.
// ❌ Source: location.hash. Sink: innerHTML. The server sees NOTHING.// URL: https://app.example/share#<img src=x onerror=fetch('/api/keys')>const title = decodeURIComponent(location.hash.slice(1));document.getElementById("preview").innerHTML = "Sharing: " + title;The payload is after the #, so it exists only in the browser. The WAF, server logs and server-side integration tests all miss it — only a real browser (or reading the code) finds it.
// ❌ postMessage with no origin check. ANY page framing this one can post.window.addEventListener("message", (e) => { // Missing e.origin check → accepts data from any frame. document.getElementById("chat").innerHTML += e.data;});// ❌ Source → location. Open redirect and javascript: URL.// URL: https://app.example/go#javascript:fetch('/api/keys')location = location.hash.slice(1); // runs javascript: or navigates to the attacker's site// After the fix: textContent instead of innerHTML, check the origin, check the scheme.preview.textContent = "Sharing: " + title; // does not parse HTMLif (e.origin !== "https://app.example") return; // only accept from our own origin// ❌ Nguồn location.hash → đích innerHTML. Payload sau dấu # không tới server.function showShareTitle() { // URL: https://app.example/share#<img src=x onerror=fetch('/api/keys')> const title = decodeURIComponent(location.hash.slice(1)); // innerHTML PARSE HTML, nên <img onerror> chạy. WAF/log/test server-side đều mù. document.getElementById("preview")!.innerHTML = "Sharing: " + title;} // ❌ postMessage không kiểm origin → nhận từ BẤT KỲ trang nào nhúng trang này.window.addEventListener("message", (e) => { // Thiếu kiểm e.origin. Một trang kẻ tấn công mở iframe tới trang này rồi // postMessage một payload, và nó chạy trong origin của ta. document.getElementById("chat")!.innerHTML += e.data;}); // ❌ Nguồn → đích location. javascript: URL và open redirect.function goToTarget() { // URL: https://app.example/go#javascript:fetch('/api/keys') // Không kiểm scheme → javascript: chạy; hoặc //evil.com → open redirect. location.href = decodeURIComponent(location.hash.slice(1));}What happened in the wild
DOM XSS in advertising and analytics libraries (many reports). Many third-party scripts read location and write to innerHTML to "personalise", and they run on millions of pages. Memorable because the vulnerability is in code you did NOT write but embedded — and it is invisible to all your server-side checks.
The postMessage family (Facebook, many SDKs, 2013–present). Embedded SDKs (login buttons, chat widgets) use postMessage to communicate between an iframe and the parent, and handlers missing an origin check accept data from the attacker's page. This is the source of the postMessage concept in block 3, and it still appears regularly.
DOM Invader (PortSwigger) and DOM Clobbering research. The tooling and research show the real scale of DOM XSS: it is more common than reflected XSS on JavaScript-heavy applications, precisely because nobody hunts it — a server-side scanner does not see it, and it needs source-to-sink flow analysis of the client code.
How to defend
Use safe sinks — textContent, not innerHTML; never eval
mandatoryThe fix is choosing a sink that does not execute. The block 3 table lists the dangerous sinks; each has a safe alternative:
textContentinstead ofinnerHTML. The browser does not parse HTML fromtextContent, so there is no context to escape. If you genuinely need HTML from user data, route it through DOMPurify (an allowlist sanitiser — see xss layer 1), not your own.- Never
eval,Function(str),setTimeout("str"),setInterval("str"). There is no safe version of putting a user string into these. - For
location: validate the scheme before assigning (see the xss topic —javascript:contains no character HTML escaping cares about). Allow onlyhttp/https, and for internal redirects allow only paths starting with/but not//(protocol-relative → open redirect).
How to find it: grep the dangerous sinks in the client code (see block 7). The sink list is finite, so this is one of the few client vulnerabilities where grep genuinely works.
// Nguồn location.hash → đích AN TOÀN textContent. Trình duyệt không parse HTML// từ textContent, nên không có ngữ cảnh nào để <img onerror> thoát ra.function showShareTitle() { const title = decodeURIComponent(location.hash.slice(1)); // Nếu cần HTML thật từ dữ liệu người dùng thì qua DOMPurify (topic xss), không tự viết. document.getElementById("preview")!.textContent = "Sharing: " + title;} const ALLOWED_ORIGIN = "https://app.example"; window.addEventListener("message", (e) => { // Kiểm origin TRƯỚC khi chạm e.data, và so BẰNG chuỗi chính xác — không endsWith // (khớp lỏng như topic cors: "evil-app.example" đi qua endsWith(".app.example")). if (e.origin !== ALLOWED_ORIGIN) return; // Và vẫn coi e.data là dữ liệu: textContent, không innerHTML. const node = document.createElement("div"); node.textContent = String(e.data); document.getElementById("chat")!.appendChild(node);}); function goToTarget() { const raw = decodeURIComponent(location.hash.slice(1)); // Kiểm scheme: javascript: không chứa ký tự nào HTML-escaping quan tâm, nên chỉ // một allowlist scheme mới chặn được nó. Và chặn // (protocol-relative → open redirect). if (raw.startsWith("/") && !raw.startsWith("//")) { location.href = raw; // chỉ đường dẫn nội bộ } else { try { const url = new URL(raw); if (url.protocol === "https:" && url.origin === ALLOWED_ORIGIN) location.href = raw; } catch { /* không parse được → không chuyển */ } }}Check `event.origin` in every postMessage handler
mandatoryA message handler with no origin check accepts data from any page — including an attacker page that has framed yours in an iframe.
window.addEventListener("message", (e) => { // Check the origin BEFORE touching e.data. Compare by EXACT string, not // endsWith/includes (the same loose-match bug as the cors topic). if (e.origin !== "https://app.example") return; // ... and still treat e.data as data, not raw innerHTML.});Two commonly-wrong details:
- Compare
originby exact string, notendsWith(".example.com")— the same loose-match bug as the cors topic, andevil-example.comwalks through. - The SENDER also specifies a concrete targetOrigin:
postMessage(data, "https://app.example"), notpostMessage(data, "*")—*sends the data to whatever page currently occupies that frame.
CSP with Trusted Types closes the whole family at the API level
This is the only control that acts on the whole DOM XSS family in one place, rather than fixing each sink.
Content-Security-Policy: require-trusted-types-for 'script' makes the dangerous sinks (innerHTML, eval, Function) reject a raw string — they accept only a TrustedHTML/TrustedScript produced by a registered policy. So an element.innerHTML = userString throws at runtime, turning a silent vulnerability into a visible error.
See csp layer 1b — this is one of the four commonly-omitted directives. It is layer 2 rather than layer 1 because it needs browser support and needs the code moved to use a policy, but it is the only control that catches a new sink somebody adds later.
Add a nonce-based CSP script-src (the csp topic) to bound the damage if a sink slips through.
Detection: scan source-to-sink flows, not requests
DOM XSS leaves no server trace, so detection has to be at the client layer and in CI.
- Static source-to-sink flow analysis in CI: tools like CodeQL, or ESLint with a security plugin, trace
location.hash → innerHTML. This is the only way to find it systematically, because grep sees the sink but not that the data reaches it from an untrusted source. - DOM Invader / dynamic testing in a browser against staging: it injects a marker into every source and watches whether the marker reaches a sink.
- CSP
report-uriwith Trusted Types on: a Trusted Types violation is a sink receiving a raw string — that is, a potential DOM XSS, reported while it happens.
Layer 3 because it does not block, but for a family where every server-side detection is blind, client-side analysis is the only way to know it exists.
Verifying the fix
1. Grep the dangerous SINKS in the client code — the cheapest check, and the sink list is finite:
grep -rnE '\.innerHTML|\.outerHTML|\beval\(|new Function\(|setTimeout\([^,]*[a-z]' \ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.vue' src/ \ | grep -v 'textContent\|DOMPurify\|sanitize' \ && { echo "dangerous DOM sink — review its source"; exit 1; }exit 02. Grep postMessage handlers with no origin check:
grep -rnE 'addEventListener\(\s*["\x27]message["\x27]' -A6 \ --include='*.ts' --include='*.tsx' --include='*.js' src/ \ | grep -L 'e\.origin\|event\.origin' \ && echo "a message handler may be missing an origin check"# And grep postMessage(data, "*") — sends to any origin.grep -rnE 'postMessage\([^,]+,\s*["\x27]\*["\x27]' --include='*.ts' --include='*.tsx' src/ \ && { echo "postMessage targetOrigin=* — sends to any page"; exit 1; }exit 03. Static source-to-sink flow analysis — grep sees the sink; flow analysis sees the DATA reaching the sink from an untrusted source. Run the CodeQL query js/dom-xss in CI. This is the only check that finds it systematically.
4. Test in a REAL BROWSER — the only check that reproduces it, because a fragment payload does not reach the server:
// Playwright. A payload in the hash → assert it does NOT run.test("the hash does not become XSS", async ({ page }) => { await page.goto(`${BASE}/share#<img src=x onerror=window.__pwned=1>`); expect(await page.evaluate(() => window.__pwned)).toBeUndefined(); // And the string appears as TEXT, which is correct. expect(await page.textContent("#preview")).toContain("<img");});5. Check Trusted Types is on (layer 2):
curl -sI https://app.example/ | grep -i content-security-policy \ | grep -q "require-trusted-types-for" || echo "WARNING: Trusted Types missing"import { test, expect } from "@playwright/test"; const BASE = "http://localhost:3100"; // DOM XSS chỉ tái hiện được trong TRÌNH DUYỆT THẬT: payload nằm sau dấu #, nên nó// không bao giờ tới server, và không integration test server-side nào thấy nó.// Đây là điểm khác biệt cốt lõi so với reflected/stored XSS. test.describe("DOM XSS", () => { test("hash source vào textContent không chạy", async ({ page }) => { // Nếu payload chạy, nó set window.__pwned. Khẳng định nó KHÔNG chạy. await page.goto(BASE + "/share#" + encodeURIComponent("<img src=x onerror=window.__pwned=1>")); expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined(); // Và chuỗi hiện ra dưới dạng CHỮ — đó mới là hành vi đúng của textContent. expect(await page.textContent("#preview")).toContain("<img src=x"); }); test("postMessage từ origin lạ bị bỏ qua", async ({ page, context }) => { await page.goto(BASE + "/chat"); // Mở một trang ở origin KHÁC và gửi postMessage tới trang chat. const evil = await context.newPage(); await evil.setContent( '<iframe src="' + BASE + '/chat" id="t"></iframe>' + '<scr' + 'ipt>' + ' document.getElementById("t").onload = () => {' + ' document.getElementById("t").contentWindow.postMessage(' + ' "<img src=x onerror=window.__pwned=1>", "*");' + ' };' + '</scr' + 'ipt>'); await page.waitForTimeout(500); // Handler kiểm origin nên payload từ origin lạ bị bỏ qua. expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined(); }); test("javascript: trong hash không chuyển hướng", async ({ page }) => { await page.goto(BASE + "/go#" + encodeURIComponent("javascript:window.__pwned=1")); await page.waitForTimeout(200); expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined(); // Và không rời khỏi origin của mình. expect(new URL(page.url()).origin).toBe(new URL(BASE).origin); }); test("//evil.com trong hash không open redirect", async ({ page }) => { await page.goto(BASE + "/go#" + encodeURIComponent("//evil.example/")); await page.waitForTimeout(200); expect(new URL(page.url()).hostname).not.toBe("evil.example"); });});Common mistakes
| The "fix" | Why it is wrong |
|---|---|
| Sanitise on the server | A fragment payload does NOT reach the server. DOM XSS can only be fixed client-side |
| Escape HTML and still use innerHTML | Sometimes right, but textContent is simpler and cannot be wrong. And javascript: in a location sink has no character to escape |
| Trust React to handle it | React escapes the HTML body. dangerouslySetInnerHTML, location=, eval are still dangerous sinks React does not block |
postMessage checking origin.endsWith(...) | Loose matching like the cors topic. evil-example.com walks through. Compare by exact string |
postMessage(data, "*") | Sends the data to whatever page occupies the frame. Specify a concrete targetOrigin |
| A WAF blocking XSS payloads | The fragment does not reach the WAF. There is nothing for it to see |
| Testing only reflected/stored XSS | A server-side test has no request containing the fragment payload. It needs a real browser |
The location mistake, and it is the central one: hunting and fixing on the server. DOM XSS is a wholly client-side bug — the source, the sink and the execution are all in the browser. You find it by tracing source → sink in JavaScript, not by scanning requests.
The tooling mistake: using a server-side scanner. They send a request and inspect the response — but a DOM XSS payload lives in a fragment that never reaches the server, so the scanner sees a clean page. You need client-side flow analysis (CodeQL) or a real-browser test (DOM Invader, Playwright).
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…