VNX-NODE-012 – Client-Side XSS via innerHTML or v-html
Overview
This rule detects a broader set of DOM-based XSS sinks beyond React’s dangerouslySetInnerHTML: .innerHTML =, .outerHTML =, document.write(), document.writeln(), Vue’s v-html directive, and jQuery’s .html() method. All of these inject raw HTML into the live DOM. When the content is dynamic — fetched from an API, derived from URL parameters, read from localStorage, or received from a WebSocket — an attacker who can influence that content can inject <script> tags or inline event handlers that execute in the victim’s browser. This is CWE-79 (Improper Neutralization of Input During Web Page Generation — Cross-site Scripting).
Severity: High | CWE: CWE-79 – Improper Neutralization of Input During Web Page Generation (XSS)
Why This Matters
DOM-based XSS is particularly subtle because the malicious payload never reaches the server — it exists entirely in the client-side code path and may not be detected by server-side WAFs or input validation. An attacker can craft a URL with a fragment or query parameter that a JavaScript-heavy application reads and injects into the DOM. The payload steals session cookies, reads localStorage tokens, silently submits forms, or injects persistent content into the page.
document.write() is especially dangerous because it can completely replace the page content after load, and its usage in modern applications is almost always a legacy anti-pattern. Vue’s v-html is the framework-specific equivalent of innerHTML — it bypasses Vue’s built-in template XSS protections and must only be used with pre-sanitized content.
What Gets Flagged
The rule matches any line containing .innerHTML =, .innerHTML=, .outerHTML =, .outerHTML=, document.write(, document.writeln(, v-html=, dangerouslySetInnerHTML, or the jQuery pattern $(...).html(.
// FLAGGED: innerHTML from API response
fetch('/api/user-bio').then(r => r.text()).then(bio => {
document.getElementById('bio').innerHTML = bio;
});
// FLAGGED: document.write with dynamic content
document.write('<div>' + location.hash.substring(1) + '</div>');
// FLAGGED: jQuery .html() with dynamic value
$('#notification').html(apiResponse.message);
<!-- FLAGGED: Vue v-html with dynamic data -->
<div v-html="userBio"></div>
Remediation
Replace
.innerHTMLwith.textContentfor plain text content. This is the most common safe fix —textContentsets the text node value without parsing HTML:// SAFE: textContent does not parse HTML document.getElementById('bio').textContent = bio;Use framework-native safe bindings instead of raw HTML injection. In Vue, use
{{ variable }}template syntax (which HTML-escapes automatically) instead ofv-html:<!-- SAFE: Vue double-curly escapes HTML automatically --> <div>{{ userBio }}</div> <!-- SAFE: only use v-html with pre-sanitized content --> <div v-html="sanitizedBio"></div>Sanitize with DOMPurify when HTML rendering is genuinely required:
// SAFE: DOMPurify strips dangerous elements and attributes import DOMPurify from 'dompurify'; document.getElementById('content').innerHTML = DOMPurify.sanitize(richText); // In Vue with v-html: computed: { sanitizedBio() { return DOMPurify.sanitize(this.userBio); } }Replace jQuery
.html()with.text()for text content, or sanitize before.html():// SAFE: jQuery .text() escapes HTML $('#notification').text(apiResponse.message); // SAFE: sanitize before .html() if markup is needed $('#content').html(DOMPurify.sanitize(apiResponse.html));Eliminate
document.write()entirely. There is no safe modern use ofdocument.write(). Replace it withdocument.createElement()andtextContent, or manipulate the DOM directly after page load.Set a Content Security Policy header that disallows
'unsafe-inline'scripts and restricts script sources. This is defence-in-depth that limits injection impact even when a sink is present.