Skip to content

Defending Against XSS

Every page in this cluster exploits the same root cause: untrusted input rendered as executable content without context-aware encoding. This page is the other side - what removes the vulnerability, what only narrows it, and what to recommend in a report. It is ordered by effectiveness: the first control eliminates the class, the rest are defence in depth.

1. Context-aware output encoding (the actual fix)

Section titled “1. Context-aware output encoding (the actual fix)”

XSS is an output problem, not an input problem. The fix is to encode data for the exact context it is rendered into, at the moment it is rendered:

Render context Encode as
HTML body / text HTML-entity encode (< → &lt;, & → &amp;)
HTML attribute value Attribute-encode and always quote the attribute
Inside a <script> / JS string An HTML-aware JavaScript-string encoder - plain JS escaping is not enough, because a literal </script> in the data closes the element before the JS parser runs, so the encoder must also escape < / / (or at least the </script> sequence). Better: don’t put data in script at all - emit it as JSON in a data- attribute (HTML-attribute-encoded) and read it with JSON.parse(el.dataset.x).
URL attribute (href, src) URL-encode and allowlist the scheme (reject javascript:)
CSS CSS-escape; avoid putting data in <style> at all

The critical word is context: HTML-encoding a value that lands inside a JavaScript string does nothing. Encode for where it actually goes.

Modern frameworks auto-escape by default, which is why they prevent most XSS for free - the fix is to not opt out:

  • React escapes values in JSX; the hole is dangerouslySetInnerHTML.
  • Angular escapes interpolated bindings; the holes are [innerHTML] and bypassSecurityTrust*.
  • Vue escapes {{ }}; the hole is v-html.
  • Server-side templates vary - don’t assume. Razor and Thymeleaf auto-escape by default; Jinja only escapes when autoescape is on (Flask enables it for .html, but a bare jinja2.Environment defaults to off), and plain Ruby ERB does not HTML-escape at all (Rails’ ERB does, via <%= %> + its integration). Using these engines directly, you must enable auto-escaping (autoescape=True) or encode explicitly (ERB: <%= ERB::Util.html_escape(x) %> / Rails h). Then the remaining hole is the “raw”/“safe” opt-out (|safe, {{{ }}}, raw, .html_safe).

3. Content-Security-Policy and Trusted Types

Section titled “3. Content-Security-Policy and Trusted Types”

CSP is a second line that limits what injected script can do even if encoding fails:

  • A strict policy - per-request nonces or hashes plus 'strict-dynamic', and no unsafe-inline - blocks inline event handlers and injected <script>. Leave 'self' out of script-src: keeping it lets an injected <script src=/…> pull in any same-origin script without a nonce, including JSONP endpoints that echo a callback and uploaded/user-controlled files - a common CSP bypass. A policy that keeps unsafe-inline buys almost nothing either (see CSP bypass).
  • Trusted Types (require-trusted-types-for 'script') attacks DOM-based XSS specifically: it makes dangerous sinks (innerHTML, eval, script.src) reject plain strings, forcing data through a vetted policy. This is the strongest single control against the DOM-based variant.

CSP is defence in depth, not a substitute for encoding - treat a CSP finding as “reduce blast radius,” never as the primary fix.

Some features legitimately need to render user HTML (rich-text comments, WYSIWYG). Don’t hand-roll a filter - use a maintained, allowlist-based sanitizer:

  • DOMPurify (client-side, and server-side via jsdom) is the standard: DOMPurify.sanitize(dirty) strips scripts and dangerous attributes against an allowlist.

Allowlist sanitization (permit known-safe tags/attributes, drop everything else) is robust; blocklist filtering (strip <script>, strip onerror) is not - the whole Filter bypasses page is reasons why.

For the DOM-based class specifically, prevention is choosing non-executing sinks in the client code:

  • Use textContent / innerText, not innerHTML, to insert text.
  • Use setAttribute with validated values, not string-built markup.
  • Never pass a user-derived string to eval, Function, or setTimeout/setInterval.
  • Validate postMessage handlers against event.origin before using the data.

Real, but none closes the hole - never present one as the primary remediation:

  • HttpOnly cookies make document.cookie unreadable, blocking the simplest session-theft path. They do not stop XSS - an attacker can still act in the session (CSRF-style) and scrape the DOM. Pair with Secure and a sensible SameSite.
  • Input validation (allowlist by type/format) shrinks the surface and is good practice, but data valid for its field can still be malicious in the wrong output context. It complements output encoding; it doesn’t replace it.
  • WAF catches mass scanning and known payloads, but pattern rules are routinely evaded (Filter bypasses). A WAF in front of an injectable app is still injectable.
  • X-XSS-Protection is obsolete - the browser auditors it controlled have been removed from Chrome and Edge, and it could introduce its own bugs. Set it to 0 and rely on CSP instead; do not recommend enabling it.
  • X-Content-Type-Options: nosniff stops the browser MIME-sniffing a response into something executable, so it blunts XSS that depends on content served with a non-HTML Content-Type being rendered as HTML (an uploaded “image”, a reflected non-HTML endpoint). Worth setting, but it only covers the sniffing vector - it does nothing for content already served as HTML.

Lead with the fix that removes the class:

  1. Primary: context-aware output encoding at every sink (or: stop disabling the framework’s auto-escaping on the affected values).
  2. Rich-HTML sinks: replace any hand-rolled filtering with allowlist sanitization (DOMPurify).
  3. Defense in depth: a strict script-src CSP built on nonces/hashes + 'strict-dynamic', without 'self' or unsafe-inline; Trusted Types for DOM sinks.
  4. Containment: HttpOnly + Secure + SameSite cookies to limit what a successful XSS can steal.

Avoid recommending “add a WAF,” “enable X-XSS-Protection,” or “filter <script>” as the main remediation - the first two are weak or obsolete and the third is a blocklist that bypasses defeat.

  • DOM-based XSS - the sinks sections 3 and 5 are designed to neutralise
  • Filter bypasses - why blocklists and loose CSP can’t be relied on
  • XSS to session - the theft that HttpOnly blocks (and what it doesn’t)