# XSS prevention through proper output escaping

- **Pattern:** `ab-002405` (`security-hardening.input-validation.xss-prevention`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-20
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002405
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002405)

## 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:

```ts
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 `rg` is 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.

  ```detect-rg
  rg -l -e "dangerouslySetInnerHTML" -e "innerHTML\s*=" -e "document\.write\(" -e "new Function\(" src/
  ```

- **Pass criteria:** No `dangerouslySetInnerHTML` with 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. No `innerHTML` assignments 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. Direct `innerHTML` assignment with user-controlled content. `eval()` or `new 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:** `dangerouslySetInnerHTML` or `v-html` is 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:

  ```ts
  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 `dangerouslySetInnerHTML` entirely — use a markdown renderer like `react-markdown` instead for user-authored content.

## External references

- cwe CWE-79
- owasp A03
- nist SI-10

Taxons: injection-and-input-trust

HTML version: https://auditbuffet.com/patterns/ab-002405
