Cross-site scripting

Put untrusted text on a page without giving it the ability to run.

02of 16
≈ 14Min read
6Parts

What it is

Cross-site scripting is any way an attacker gets their JavaScript running on your page, with your origin, your cookies and your DOM. Once that happens there is no boundary left: they can read the page, call your API as the user, change what is displayed, and send it all somewhere else.

It is nearly always the same root cause. Somewhere, text that came from a user was put into a page in a place where the browser parses code.

Three kinds

The third is now the most common, because so much rendering happens in the browser. It never appears in a server log and no amount of server-side escaping touches it.

KindThe payload livesExample
Storedin your databasea product review containing a <script> tag, shown to every visitor
Reflectedin the URL?q=<img onerror=…> echoed into the results page, sent as a link
DOM-basednever leaves the browserelement.innerHTML = location.hash — no server involved at all

Escaping is contextual

A function called escape() that does one of these is a function that is wrong in the other three. In HTML text, " is harmless; inside an attribute it ends the attribute. In an href, entities do nothing at all against javascript:.

the same value, four places, four rules
<div>        HERE </div>                     → & < > become entities
<div title="HERE">                            → also " and '
<a href="HERE">                               → must be a safe scheme, and encoded
<script> var x = "HERE" </script>             → JS string escaping — and still risky
<div style="width: HERE">                     → CSS escaping — avoid entirely
Never build a script or a style from user data

There is no reliable escaping for "inside a <script> tag" — </script> inside a string ends the block, and the parser rules are full of edge cases. Pass data as JSON in a <script type="application/json"> block and read it with JSON.parse(el.textContent), or put it in a data- attribute.

The safe defaults

Modern frameworks made XSS rare by making the safe thing the default and the unsafe thing long to type. The remaining bugs cluster in exactly three places: innerHTML (and the framework equivalent), href/src built from input, and anything rendering HTML that came from a rich-text editor.

in plain DOM, and in a framework
element.textContent = userInput;          // safe: never parsed as HTML
element.setAttribute("title", userInput);  // safe
element.innerHTML = userInput;             // the bug

// React, Vue, Svelte, Angular all escape by default:
<p>{userInput}</p>                         // safe
<p dangerouslySetInnerHTML={{ __html: userInput }} />   // you asked for it

When you really do need HTML

Write the list of what is allowed, not a list of what is banned — a blocklist is a guess about an attacker's imagination. And use a maintained library: the edge cases (<svg>, <math>, mutation XSS, namespace confusion) are genuinely beyond a regular expression.

sanitise with a library, on a allowlist
import DOMPurify from "dompurify";

element.innerHTML = DOMPurify.sanitize(comment, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
  ALLOWED_ATTR: ["href"],
});

The layers behind the escaping

  • CSP — script-src 'self' means an injected inline script does not run even when your escaping fails. This is the single best second line.
  • HttpOnly cookies — a successful XSS can still act as the user, but cannot copy the session out.
  • Trusted Types — makes innerHTML throw unless the value went through a policy you wrote. Chrome and Edge today.
  • Frameworks — keep the escaping on. Every dangerouslySetInnerHTML deserves a comment saying why.

Practice