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 (< → <, & → &) |
| 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.
2. Let the framework do it
Section titled “2. Let the framework do it”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]andbypassSecurityTrust*. - Vue escapes
{{ }}; the hole isv-html. - Server-side templates vary - don’t assume. Razor and Thymeleaf auto-escape by default; Jinja only escapes when
autoescapeis on (Flask enables it for.html, but a barejinja2.Environmentdefaults 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) %>/ Railsh). 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 nounsafe-inline- blocks inline event handlers and injected<script>. Leave'self'out ofscript-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 keepsunsafe-inlinebuys 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.
4. Sanitize when you must allow HTML
Section titled “4. Sanitize when you must allow HTML”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.
5. Safe DOM APIs
Section titled “5. Safe DOM APIs”For the DOM-based class specifically, prevention is choosing non-executing sinks in the client code:
- Use
textContent/innerText, notinnerHTML, to insert text. - Use
setAttributewith validated values, not string-built markup. - Never pass a user-derived string to
eval,Function, orsetTimeout/setInterval. - Validate
postMessagehandlers againstevent.originbefore using the data.
6. Controls that only reduce risk
Section titled “6. Controls that only reduce risk”Real, but none closes the hole - never present one as the primary remediation:
HttpOnlycookies makedocument.cookieunreadable, 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 withSecureand a sensibleSameSite.- 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-Protectionis obsolete - the browser auditors it controlled have been removed from Chrome and Edge, and it could introduce its own bugs. Set it to0and rely on CSP instead; do not recommend enabling it.X-Content-Type-Options: nosniffstops the browser MIME-sniffing a response into something executable, so it blunts XSS that depends on content served with a non-HTMLContent-Typebeing 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.
For the report
Section titled “For the report”Lead with the fix that removes the class:
- Primary: context-aware output encoding at every sink (or: stop disabling the framework’s auto-escaping on the affected values).
- Rich-HTML sinks: replace any hand-rolled filtering with allowlist sanitization (DOMPurify).
- Defense in depth: a strict
script-srcCSP built on nonces/hashes +'strict-dynamic', without'self'orunsafe-inline; Trusted Types for DOM sinks. - Containment:
HttpOnly+Secure+SameSitecookies 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.
Related
Section titled “Related”- 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
HttpOnlyblocks (and what it doesn’t)