SecLab

DOM-based vulnerabilities

A05CWE-79
01

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.

02

Why you should care

Relevance: CoreExpected: L2

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, and eval are 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.

03

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.

Diagram source
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, .searchinnerHTMLXSS
postMessage (no origin check)innerHTML, evalXSS from another frame
location.hashlocation = ...open redirect, javascript: URL
document.referrerinnerHTMLXSS via the referring page
any sourceeval, 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.

04

Concrete example

A "share" widget reads the page title from the fragment to show a heading — and the fragment never reaches the server.

JavaScript
// ❌ 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.

JavaScript
// ❌ 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;});
JavaScript
// ❌ 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
JavaScript
// 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
TypeScriptThree source-to-sink flows, and the server sees no payload in any of them.
// ❌ 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));}
05

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.

06

How to defend

Layer 1

Use safe sinks — textContent, not innerHTML; never eval

mandatory

The fix is choosing a sink that does not execute. The block 3 table lists the dangerous sinks; each has a safe alternative:

  • textContent instead of innerHTML. The browser does not parse HTML from textContent, 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 only http/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.

TypeScript · Layer 1textContent instead of innerHTML, an exact origin check, and a scheme check before assigning location.
// 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 */ }  }}
Layer 1b

Check `event.origin` in every postMessage handler

mandatory

A message handler with no origin check accepts data from any page — including an attacker page that has framed yours in an iframe.

JavaScript
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 origin by exact string, not endsWith(".example.com") — the same loose-match bug as the cors topic, and evil-example.com walks through.
  • The SENDER also specifies a concrete targetOrigin: postMessage(data, "https://app.example"), not postMessage(data, "*")* sends the data to whatever page currently occupies that frame.
Layer 2

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.

Layer 3

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-uri with 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.

07

Verifying the fix

1. Grep the dangerous SINKS in the client code — the cheapest check, and the sink list is finite:

Shell
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 0

2. Grep postMessage handlers with no origin check:

Shell
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 0

3. 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:

JavaScript
// 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):

Shell
curl -sI https://app.example/ | grep -i content-security-policy \  | grep -q "require-trusted-types-for" || echo "WARNING: Trusted Types missing"
TypeScriptA real-browser test — a fragment payload does not reach the server, so only a browser reproduces it.
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");  });});
08

Common mistakes

The "fix"Why it is wrong
Sanitise on the serverA fragment payload does NOT reach the server. DOM XSS can only be fixed client-side
Escape HTML and still use innerHTMLSometimes right, but textContent is simpler and cannot be wrong. And javascript: in a location sink has no character to escape
Trust React to handle itReact 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 payloadsThe fragment does not reach the WAF. There is nothing for it to see
Testing only reflected/stored XSSA 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).

09

References

Tier 1Window: postMessage() — security concerns · MDN · Web API reference · 2025
Tier 1Trusted Types · W3C · Working Draft · 2025
Tier 2DOM-based vulnerabilities · PortSwigger · Web Security Academy
Tier 2DOM based XSS Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Tier 2DOM Clobbering Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Tier 3Introducing DOM Invader · PortSwigger
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

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.

Sign upSign in

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…