dom-vulnerability-detection · diff
git:20260706.00661a3 to git:20260820.ee77f46
147 added, 1 removed. Audit B to B.
---
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.
- **Checkpoint:** For each handler, verify: (1) strict `e.origin` equality check exists, (2) no `window.origin` comparison, (3) no `startsWith`/`endsWith` on origin, (4) data is not passed to dynamic execution (`window[data.func]`).
+ #### 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)