← Back to the library
SecurityCore · 8 min

User-submitted content runs scripts in other users' browsers

“A comment, profile field, or markdown post renders as live HTML: an injected image fires an onerror handler, a link runs javascript: when clicked, or a pentest reports stored XSS.”

THE PRINCIPLE

Encode for the exact output context and keep untrusted strings away from HTML sinks. CSP limits damage; it is not the primary fix.

FIRST MOVES

  1. Grep for every raw sink: dangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, v-html, bypassSecurityTrust*, unsafeHTML, and markdown renderers with HTML enabled.
  2. Render user data as text wherever possible (JSX children, textContent); never build HTML strings by concatenation.
  3. Where rich HTML is a feature, sanitize with a maintained sanitizer such as DOMPurify at render time, and do not modify its output afterwards.
  4. Allowlist http: and https: for user-supplied href and src values; reject javascript: and data: URLs.
  5. Ship a strict CSP with a fresh unpredictable nonce per response (object-src 'none'; base-uri 'none'), then add require-trusted-types-for 'script'.

TOOLS: THEN → NOW

Escaping or stripping tags on inputContext-aware output encoding plus sanitizing at the HTML sink
Domain allowlist CSPsNonce- or hash-based strict CSP, optionally with 'strict-dynamic'
Code review to find innerHTMLTrusted Types enforcement (Baseline 2026) so raw strings to sinks throw

PATTERN SNAPSHOT

import DOMPurify from "dompurify";

// Plain text: React escapes it.
export const Comment = ({ body }: { body: string }) => <p>{body}</p>;

// Rich text that must be HTML: sanitize immediately before the sink.
// TrustedHTML output keeps working once Trusted Types is enforced.
export const RichComment = ({ html }: { html: string }) => (
  <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }) }} />
);

// User-supplied links: allowlist schemes and return the parsed URL.
export function safeHref(raw: string): string | undefined {
  try {
    const url = new URL(raw);
    return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined;
  } catch { return undefined; }
}

CLOSE THE AI. EXPLAIN THIS.

Why does a strict CSP not make it safe to pass unsanitized user HTML to innerHTML, and what does Trusted Types add on top?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed