dom-vulnerability-detection · git:20260820.ee77f46 · 2026-08-20 · sha256 b05903b72d933d92

dom-vulnerability-detection git:20260820.ee77f46B

Immutable. This exact content is served forever at /api/v1/blob/b05903b72d933d92.

---
name: dom-vulnerability-detection
description: DOM-based XSS and client-side vulnerability detection via dynamic analysis -- trace attacker-controlled sources to dangerous sinks, audit postMessage handlers, test CSTI in Angular/Vue/htmx, and check for DOM clobbering. Use when auditing JavaScript code, reviewing client-side security, analyzing DOM manipulation, or testing postMessage handlers.
---

## Sources (attacker-controlled input)
`location.hash`, `location.search`, `location.href`, `document.URL`, `document.referrer`, `window.name`, `postMessage event.data`, `document.cookie`, `localStorage`/`sessionStorage`

## Sinks (dangerous output)
`innerHTML`, `outerHTML`, `document.write()`, `eval()`, `setTimeout(string)`, `new Function()`, `location.href=`, `location.assign()`, `element.src=`, `jQuery.html()`, `$.globalEval()`, `v-html`, `ng-bind-html`, `dangerouslySetInnerHTML`

## Analysis workflow

### 1. Find sources reaching sinks
```bash
# Search for dangerous sinks in JS files
rg "innerHTML|outerHTML|document\.write|\.html\(" --type js --type ts -n src/
rg "eval\(|setTimeout\(|setInterval\(|new Function\(" --type js -n src/

# Search for postMessage handlers without origin checks
rg "addEventListener.*message" --type js -n src/

# ...but that grep alone MISSES most real-world handlers. Also search:
rg "onmessage\s*=" --type js -n src/                    # setter assignment
rg "MessagePort|MessageChannel|\.port[12]?\." --type js -n src/   # MessageChannel
```

**A grep for `addEventListener("message")` is not a complete listener inventory.** See
*Listeners you will miss* below before concluding a page has no handlers.

### 2. Trace data flow
For each sink found, trace backwards: does attacker-controlled input reach it?
- Direct: `element.innerHTML = location.hash.slice(1)`
- Via variable: `const data = getParam('q'); ... el.innerHTML = data`
- Via storage: `localStorage.setItem('x', userInput); ... el.innerHTML = localStorage.getItem('x')`

**Checkpoint:** For each sink, document: source -> transformations -> sink. If no attacker-controlled source reaches the sink, mark as not exploitable and move on.

### 3. Check sanitization
If sanitization exists, verify it is adequate:
- DOMPurify -> check version, config (is `ALLOW_UNKNOWN_PROTOCOLS` set?)
- Custom sanitizer -> see `custom-sanitizer-audit` skill
- Framework auto-escaping -> verify not bypassed by `v-html`, `dangerouslySetInnerHTML`, `[innerHTML]`

### 4. Audit postMessage handlers
```javascript
// VULNERABLE -- no origin check
window.addEventListener('message', (e) => {
  document.getElementById('output').innerHTML = e.data.html;
});

// SAFE -- origin validated
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://trusted.com') return;
  // ... process e.data
});
```

**targetOrigin bypass via IP normalization:** When `postMessage(data, targetOrigin)` uses regex validation like `/https?:\/\/[^.]+[.]target[.]com/`, the `[^.]+` class matches `/` -- so `http://2130706433/.target.com` passes the regex. The browser's URL parser then normalizes the integer IP to `127.0.0.1` and sends the message to `http://127.0.0.1` (attacker-controlled). Same technique works with hex (`0x7f000001`) and octal IP forms. Check: does the sender validate `targetOrigin` with regex rather than strict string equality? If yes, test integer IP + path injection.

#### Origin validation anti-patterns

Anything other than strict equality against a fixed string is suspect. Full operator list:

| Pattern | Why it fails | Bypass |
|---|---|---|
| `origin.indexOf('example.com') !== -1` | substring match anywhere | `https://evil-example.com`, `https://example.com.evil.tld` |
| `origin.includes('example.com')` | same as above | same |
| `origin.startsWith('https://example.com')` | no end boundary | `https://example.com.evil.tld` |
| `origin.endsWith('example.com')` | no start boundary | `https://evilexample.com` |
| `origin.search('example.com')` | substring, and `.` is a regex wildcard | `https://exampleXcom` |
| `origin == x` / `origin != x` | loose equality | type-juggling edge cases |
| `e.origin.match(/re/)` without anchors | matches anywhere in string | see regex rules below |

**Regex quality — two bugs that look correct at a glance:**

1. **Unescaped dot.** `/^https:\/\/trusted.example\.com$/` — the first `.` is a wildcard, so
   `https://trustedXexample.com` passes. Check every `.` between the scheme and TLD is `\.`.
2. **Missing end anchor.** `/^https:\/\/trusted\.example\.com/` (no `$`) allows any suffix, so
   `https://trusted.example.com.evil.tld` passes.

Both are trivially missed in review. When you see an origin regex, read it character by character for
unescaped `.` and a terminating `$`.

**Also flag:** `postMessage(data, '*')` in the *reply* path. A handler may validate the inbound origin
correctly and then leak the response to any listener via a wildcard `targetOrigin`.

#### Listeners you will miss

A registered listener is often **not** the function the app author wrote. Error-monitoring and framework
libraries wrap handlers, so reading the registered function shows you the monitoring shim, not the logic.
Unwrap before reviewing:

| Library | Tell | Recover original from |
|---|---|---|
| Sentry | `fn.__sentry_original__` is a function | `fn.__sentry_original__` |
| New Relic | `fn["nr@original"]` present | `fn["nr@original"]` |
| Rollbar | `fn._isWrap`, `rollbarContext`/`rollbarWrappedError` in source | `fn._wrapped`, else `fn._rollbar_wrapped` |
| Raven | `.deep…apply…captureException` in source | the single function-valued own property |
| Bugsnag | `autoNotify`/`notifyException` in source, `fn.bugsnag` is a function | **not recoverable** — read the app handler from source instead |
| Bugsnag (alt) | `fn.__trace__` is a function | **not recoverable** — same |
| Zone.js / Vue / React | framework zone or error-boundary wrapper | varies; unwrap by inspecting own properties |

Unwrapping is recursive — a handler can be wrapped more than once (e.g. Sentry inside Zone.js).

Registration surfaces that never match an `addEventListener` grep:

- **`window.onmessage = fn`** — setter assignment, not `addEventListener`.
- **`MessagePort.prototype.addEventListener`** — MessageChannel/`port.onmessage` handlers are an entirely
  separate channel, common in iframe/worker bridges and SDKs.

At runtime, capture what is actually registered rather than trusting a source grep. Hook the
registration paths **before** the app's own scripts run, then read back what was collected:

```javascript
// agent-browser eval, or a DevTools "run before page load" snippet.
// Must execute before app JS; otherwise earlier registrations are missed.
globalThis.__seen = [];
const realAEL = Window.prototype.addEventListener;
Window.prototype.addEventListener = function (type, fn, opts) {
  if (type === 'message') {
    globalThis.__seen.push({ via: 'addEventListener', src: String(fn).slice(0, 400) });
  }
  return realAEL.call(this, type, fn, opts);
};
const realPortAEL = MessagePort.prototype.addEventListener;
MessagePort.prototype.addEventListener = function (type, fn, opts) {
  if (type === 'message') {
    globalThis.__seen.push({ via: 'MessagePort', src: String(fn).slice(0, 400) });
  }
  return realPortAEL.call(this, type, fn, opts);
};
// window.onmessage = fn  bypasses both of the above:
Object.defineProperty(window, 'onmessage', {
  set(fn) { globalThis.__seen.push({ via: 'onmessage', src: String(fn).slice(0, 400) }); }
});
// ...load/interact with the page, then:  globalThis.__seen
```

Detecting whether *something else* already hooked `addEventListener`:

```javascript
Window.prototype.addEventListener.toString().includes('native code')
// false => a wrapper is installed (an extension, or the app itself)
```

**This is a weak signal, not proof.** A wrapper can trivially spoof it by overriding `toString`, and a
false result may simply be your own tooling or another browser extension. Treat it as a hint that the
registration path is instrumented, not as evidence about the application.

**Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `indexOf`/`includes`/`startsWith`/`endsWith`/loose-equality on origin, (4) any origin regex has escaped dots and a `$` anchor, (5) data is not passed to dynamic execution (`window[data.func]`), (6) the reply path does not use `postMessage(..., '*')`, (7) you have unwrapped monitoring wrappers and checked `onmessage`/`MessagePort` surfaces.

### 5. Test CSTI (Client-Side Template Injection)
- **AngularJS**: `{{constructor.constructor('alert(1)')()}}`
- **Vue.js**: check if user input reaches `v-html` or template interpolation
- **htmx**: `hx-get`, `hx-post` with user-controlled URLs

### 6. Check browser quirks
- **DOM clobbering**: `<form id="x"><input name="action" value="javascript:alert(1)">` -- overwrites `document.x.action`
- **Mutation XSS**: HTML that passes sanitizer but mutates in browser DOM -- see `dompurify-mxss-bypass` skill
- **Prototype pollution**: `__proto__` in URL params or JSON reaching `Object.assign`/spread

### 7. Verify exploitability
Build a PoC proving attacker-controlled input triggers the sink:
```
https://target.com/page#<img src=x onerror=alert(document.domain)>
```
**Checkpoint:** Confirm payload executes (not just reflected). Check CSP -- if blocked, see `csp-bypass` skill.

## Optional: bulk-triage listener bodies with FransyTracker's ruleset

When you have harvested many listener bodies (jxscout, `agent-browser eval`, source review), you can
machine-triage them before reading each by hand. [FransyTracker](https://gitlab.com/joaxcar/fransytracker)'s
rules engine is a self-contained module with no Chrome or DOM dependency, so it runs standalone:

```bash
git clone https://gitlab.com/joaxcar/fransytracker && cd fransytracker && npm install
npx tsx -e "
import './src/shared/findings.ts';
const F = (globalThis as any).FransyTrackerFindings;
console.log(JSON.stringify(F.evaluateListener({
  listener: 'function(e){ var d = e.data; document.body.innerHTML = d.html; }'
}), null, 1));"
# => findings: missing-origin-check, tainted-data-to-sink (details: "innerHTML = d")
```

Ten rules: `missing-origin-check`, `weak-origin-check`, `origin-regex-unescaped-dot`,
`origin-regex-missing-anchor`, `eval-on-message-data`, `xss-sink-on-message-data`,
`location-assignment-from-data`, `tainted-data-to-sink`, `postmessage-wildcard-target`,
`missing-data-type-guard`.

**Treat a clean result as "not yet triaged", never as "safe".** Measured blind spots — each of these is a
real sink the engine does not flag:

| Pattern | Engine result |
|---|---|
| `e.source.postMessage(x, targetOrigin)` | missed entirely |
| `const {html} = e.data; el.innerHTML = html` | sink missed (destructuring) |
| `var a = e.data; var b = a; el.innerHTML = b` | sink missed (two-hop alias) |
| `$('#x').html(e.data)` | sink missed (jQuery) |
| `setTimeout(e.data.code, 0)` | sink missed |

It is strong on the origin class and weaker on the sink class, so use it to *prioritise* reading order,
not to decide what to skip. Its rules are regex over source text — minification and unusual aliasing
degrade it further.

**Opsec if you run the browser extension instead of the standalone module:** it requests
`host_permissions: *://*/*` and hooks page prototypes on every site you visit. Use a dedicated browser
profile, never your engagement-authenticated one.

## Credits
The origin anti-pattern table, wrapper-unwrapping tells, and hidden-listener surfaces above are distilled
from [FransyTracker](https://gitlab.com/joaxcar/fransytracker) (Johan Carlsson), itself an MV3 adaptation of
[postMessage-tracker](https://github.com/fransr/postMessage-tracker) by Frans Rosén and
[FancyTracker](https://github.com/Zeetaz/FancyTracker) by Erik Zettergren.

## Chain With
- `csp-bypass` (CSP blocks execution), `dompurify-mxss-bypass` (DOMPurify present), `custom-sanitizer-audit` (homegrown sanitizer), `self-xss-escalation` (payload only fires in own session)
- `cspt-xss` (Gadget 8 chains a CSPT-injected response into a postMessage listener that trusts `*.target.com`)
- `dom-vulnerability-static-analysis` (same source/sink model, applied to a repo rather than a live page)