XSS prevention through proper output escaping
Why it matters
XSS (CWE-79, OWASP A03 Injection) lets attackers inject JavaScript into pages viewed by other users — stealing session cookies, capturing keystrokes, redirecting to phishing pages, or silently performing actions as the victim. Stored XSS, where the payload is saved in a database and rendered to every visitor, can compromise thousands of accounts from a single submission. NIST 800-53 SI-10 requires input sanitization. React's JSX auto-escaping prevents most XSS, but dangerouslySetInnerHTML with unsanitized database content bypasses the protection entirely. A comment field or profile bio rendered unsanitized is all an attacker needs.
Severity rationale
High because stored XSS turns every page view into a potential account takeover — a single injected payload runs in every victim's browser session without further attacker interaction.
Remediation
Sanitize user-generated HTML with DOMPurify before passing it to dangerouslySetInnerHTML. Better: avoid dangerouslySetInnerHTML entirely by using react-markdown for user content.
When you must render raw HTML:
import sanitizeHtml from 'sanitize-html'
const clean = sanitizeHtml(userHtml, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
allowedAttributes: { a: ['href', 'rel'] },
allowedSchemes: ['http', 'https', 'mailto'],
})
// Then render:
<div dangerouslySetInnerHTML={{ __html: clean }} />
Search the codebase for dangerouslySetInnerHTML, innerHTML =, and v-html and audit each one. Any that render database-sourced content must sanitize first.
Detection
-
ID:
xss-prevention -
Severity:
high -
What to look for: Count every location where user-supplied data is rendered in HTML output. For each, classify the escaping method: (a) framework auto-escaping, (b) manual escaping, (c) dangerouslySetInnerHTML/v-html, or (d) unescaped. check for dangerous DOM mutation patterns. Search for
dangerouslySetInnerHTML,innerHTML =,document.write,eval(),new Function(),setTimeout(string),setInterval(string). Also check for server-side template injection patterns in non-React/JSX frameworks. For content rendered from user input or a database (user profiles, comments, rich text), verify that sanitization is applied before rendering. -
Detector snippet (shell-capable tools only): If
rgis available, list every source file that invokes a risky DOM-mutation API. Exit 0 with output means at least one risky call site exists — proceed to prose to classify each as sanitized or not. Exit 1 with no output means no risky call sites — likely pass. Exit >=2 — fall back to prose reasoning.rg -l -e "dangerouslySetInnerHTML" -e "innerHTML\s*=" -e "document\.write\(" -e "new Function\(" src/ -
Pass criteria: No
dangerouslySetInnerHTMLwith unsanitized user content. Dynamic content rendered via React/JSX (auto-escapes). When rich HTML must be rendered, a sanitization library (DOMPurify, sanitize-html) is applied before rendering. NoinnerHTMLassignments with user content — 100% of render points must use proper escaping. Report: "X user-data render points found, all Y properly escaped." -
Fail criteria:
dangerouslySetInnerHTML={{ __html: userContent }}without sanitization. DirectinnerHTMLassignment with user-controlled content.eval()ornew Function()with user-controlled strings. -
Skip (N/A) when: The project renders no user-generated content — it is a static marketing site or dashboard with only data the current user themselves has entered.
-
Do NOT pass when:
dangerouslySetInnerHTMLorv-htmlis used with unsanitized user input, even if other render points are safe. -
Detail on fail: Name the specific location. Example:
"dangerouslySetInnerHTML in components/CommentBody.tsx renders post content from database without sanitization — stored XSS possible if content was stored unsanitized"or"innerHTML assignment in lib/notifications.js line 22 embeds user-supplied message" -
Remediation: When you must render HTML from user content, sanitize it first:
import DOMPurify from 'dompurify' // In a browser environment: const clean = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: ['href', 'rel'], }) // Server-side (Node.js) with sanitize-html: import sanitizeHtml from 'sanitize-html' const clean = sanitizeHtml(userHtml, { allowedTags: sanitizeHtml.defaults.allowedTags, allowedAttributes: { a: ['href', 'rel'] }, }) // Then render safely: <div dangerouslySetInnerHTML={{ __html: clean }} />Prefer avoiding
dangerouslySetInnerHTMLentirely — use a markdown renderer likereact-markdowninstead for user-authored content.
External references
- cwe · CWE-79 — Cross-site Scripting (XSS)
- owasp:2021 · A03 — Injection
- nist:rev5 · SI-10 — Information Input Validation
Taxons
History
- 2026-04-18·v1.0.0·Initial import from security-hardening·automated
- 2026-04-20·v1.1.0·Add Phase 6.0 detect-rg snippet for XSS-prone DOM-mutation API detection·by cakleinman