Topics
v6.0.0
On this page · sections

XSS in React

Cross-Site Scripting (XSS) injects malicious scripts into your origin’s context. React escapes JSX text by default — but dangerouslySetInnerHTML, unsanitized markdown, and manual DOM APIs bypass that protection.

TypePersistenceTriggerReact-safe default?
StoredDB / CMSAny viewer loads pageText yes; __html no
ReflectedURL / requestVictim opens crafted linkText yes; __html no
DOM-basedClient-onlySource → sink in JSDepends on sink

Stored XSS

Definition

Attacker saves malicious HTML in persistent storage (comment, bio, CMS article). Every viewer loads the payload in your origin’s context — script steals cookies/tokens, performs actions, defaces UI.

'use client'; import DOMPurify from 'dompurify'; type Comment = { id: string; author: string; bodyHtml: string }; // Vulnerable — bodyHtml from DB, never sanitized on save function VulnerableComment({ comment }: { comment: Comment }) { return ( <div className="comment"> <strong>{comment.author}</strong> <div dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} /> </div> ); } // Mitigated — sanitize + escaped author name function SafeComment({ comment }: { comment: Comment }) { const clean = DOMPurify.sanitize(comment.bodyHtml, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em'], ALLOWED_ATTR: [], }); return ( <div className="comment"> <strong>{comment.author}</strong> <div dangerouslySetInnerHTML={{ __html: clean }} /> </div> ); }

Mitigation layers:

  • Sanitize on ingress — server-side validation before DB write is authoritative
  • Sanitize on render — DOMPurify with strict allowlists as second layer
  • CSP — contain execution when sanitization fails
  • HttpOnly cookies — limit cookie theft even if script runs

Real-world example

SVG upload stored XSS — SVG is XML with script. Serve from separate domain or sanitize aggressively; never inline untrusted SVG.

Interview: Stored XSS lives in data, not framework magic — one upload can affect many victims.

Reflected XSS

Definition

Payload in request (query param, path, header) is immediately reflected in response HTML. Attacker sends victim a crafted link — one click runs script.

'use client'; import { useSearchParams } from 'next/navigation'; // Vulnerable — query reflected via innerHTML function VulnerableSearch() { const params = useSearchParams(); const q = params.get('q') ?? ''; return ( <div dangerouslySetInnerHTML={{ __html: `Results for: ${q}` }} /> ); } // Safe — JSX text escape function SafeSearch() { const params = useSearchParams(); const q = params.get('q') ?? ''; return <p>Results for: {q}</p>; } // Attack URL: /search?q=<img src=x onerror=alert(document.domain)>

Encoding context matters — HTML, attribute, JavaScript, and URL contexts each need different encoding. encodeURIComponent is URL encoding, not HTML encoding.

Frontend introduction points: OAuth error pages reflecting error_description, client routers reading searchParams into innerHTML.

Interview: Reflected XSS works in client-only SPAs — no full page reload required.

DOM-Based XSS

Definition

Server response is static and safe. Client JS reads attacker-controlled source and writes to dangerous sink — no server log of the exploit.

'use client'; import { useEffect, useRef } from 'react'; // DOM XSS — hash written to innerHTML function VulnerableHashBanner() { const ref = useRef<HTMLDivElement>(null); useEffect(() => { if (!ref.current) return; ref.current.innerHTML = window.location.hash.slice(1); }, []); return <div ref={ref} />; } // Safe — render hash as text function SafeHashBanner() { const message = decodeURIComponent(window.location.hash.slice(1)); return <div>{message}</div>; } // javascript: in link — validate scheme explicitly function UserLink({ url, label }: { url: string; label: string }) { const safe = url.startsWith('https://') || url.startsWith('mailto:') ? url : '#'; return <a href={safe} rel="noopener noreferrer">{label}</a>; }

Common sources: location.hash, postMessage, poisoned localStorage.

Common sinks: innerHTML, eval, document.write, dynamic href with javascript:.

Always verify event.origin before using postMessage data.

Interview: DOM XSS is 100% frontend sink responsibility — SSR does not prevent client-side sinks.

Content Security Policy

Definition

CSP is an HTTP header declaring which scripts, styles, and resources may load. Injected script without matching nonce is blocked even if HTML parsing succeeded.

// middleware.ts — conceptual Next.js pattern import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", "upgrade-insecure-requests", ].join('; '); const response = NextResponse.next(); response.headers.set('Content-Security-Policy', csp); response.headers.set('x-nonce', nonce); return response; }

Primary XSS directives:

  • script-src — main brake on script execution
  • Nonce per request — server generates random value, attaches to <script nonce> and header
  • object-src 'none' — blocks Flash/plugin vectors
  • base-uri 'self' — prevents base tag hijack

Rollout: deploy Content-Security-Policy-Report-Only → collect violations → enforce.

Interview: CSP in meta tag is not enough for frame-ancestors — use HTTP header.

XSS Defense Checklist

Definition

Defense in depth across four phases:

Prevent:

  • Escape by default — JSX text, not raw HTML
  • No dangerouslySetInnerHTML without DOMPurify allowlist
  • Validate URL schemes — block javascript: and data:text/html
  • Sanitize server-side on save — client-only is bypassable
  • No eval, new Function, or string-based timers

Contain:

  • Strict CSP with per-request nonces
  • HttpOnly session cookies
  • Short-lived access tokens in memory — not localStorage

Detect:

  • CSP violation reporting endpoint
  • WAF on save and search endpoints
  • OWASP ZAP or Burp against staging in CI

Respond:

  • Purge CDN cache after fix
  • Rotate secrets and force logout
'use client'; import DOMPurify from 'dompurify'; const ALLOWED = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'], ALLOWED_ATTR: ['href', 'rel'], }; // Central safe HTML component — only path to rich render export function SafeHtml({ html, className }: { html: string; className?: string }) { const clean = DOMPurify.sanitize(html, ALLOWED); return ( <div className={className} dangerouslySetInnerHTML={{ __html: clean }} /> ); }

Real-world example

XSS + localStorage token = account takeover. Align XSS defenses with JWT storage decisions. AI-generated content — treat model output as untrusted user input.

Interview: Frontend owns every DOM sink, third-party script, and URL reflection point.

Senior Interview Summary

Mental model

Stored → Payload in DB; many victims; sanitize on write + read Reflected → Payload in URL; echoed unsafely; JSX text is safe DOM-based → Client source → sink; no server log React → Escapes text nodes; __html and manual DOM APIs bypass CSP → script-src nonce per request; Report-Only first

Practical rules

  • Inventory sinks — grep for dangerouslySetInnerHTML, innerHTML, dynamic href
  • Single SafeHtml choke point — easier audit and pen test
  • Server-side sanitization authoritative; client is second layer
  • Third-party scripts and tag managers are XSS if vendor compromised
  • CMS content is user input — broader allowlist OK, same pipeline required

What to say in interviews

I enforce server-side allowlist sanitization on save, client DOMPurify as second layer, strict CSP with nonces, HttpOnly sessions, and an immutable audit log of HTML saves. We do not skip sanitization based on role — compromised admin equals stored XSS for every user.

Common traps

  • “React prevents XSS” — only text nodes; __html remains vulnerable
  • Sanitize only on display — attacker POSTs raw HTML to API
  • encodeURIComponent is not HTML encoding
  • DOM XSS is not a backend problem

Last updated on