Chapter 43 — Cross-site scripting (XSS)
In one sentence
XSS is injection into a web page: the attacker gets their JavaScript to run in your users' browsers in your site's origin, and from there can do anything the user can.
The three types
STORED (persistent) payload saved server-side (a comment, profile,
product review) and served to every viewer.
Worst impact -- can self-propagate (Samy worm).
REFLECTED payload in the request (query param, path) echoed
straight into the response. Needs a lure (a crafted
link). One victim per click.
DOM-BASED no server involvement: client-side JS reads
attacker-controlled input (location.hash,
postMessage, document.referrer) and writes it into
a dangerous sink (innerHTML, eval, document.write).
Why it's dangerous
Once script runs in your origin it can:
- read the DOM (everything on the page the user sees)
- make authenticated requests as the user (SOP allows same-origin;
cookies attach automatically)
- read non-HttpOnly cookies, localStorage, sessionStorage
- keylog, capture form input, phish with a fake login overlay
- pivot: hit internal APIs the user's browser can reach
- persist (service worker) and self-spread (stored XSS)
HttpOnly stops cookie theft; it does not stop any of the rest.
The fix: context-aware output encoding + CSP
ROOT CAUSE: putting untrusted data into HTML without encoding it for
THAT context.
1. CONTEXT-AWARE OUTPUT ENCODING (the primary fix)
HTML body: & < > -> & < >
HTML attribute: also encode quotes; always quote attributes
JavaScript: don't. Serialise as JSON into a data attribute
or <script type="application/json">, then read it.
URL: percent-encode; validate the scheme (no javascript:)
CSS: avoid entirely; strict allowlist
Modern frameworks (React, Angular, Vue, Svelte) auto-encode by
default. XSS then comes from the ESCAPE HATCHES:
React dangerouslySetInnerHTML
Angular bypassSecurityTrust*
Vue v-html
any .innerHTML, document.write, jQuery .html(), eval,
new Function, setTimeout("string")
2. FOR RICH HTML INPUT (user must submit formatted text):
sanitise with a vetted library -- DOMPurify -- with a strict
allowlist. Never a homemade regex "sanitiser".
3. CONTENT SECURITY POLICY (defence in depth -- assume 1 & 2 will
sometimes fail)
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-<random-per-response>';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
- NO 'unsafe-inline', NO 'unsafe-eval', NO broad host allowlists
(they're bypassable via JSONP/old Angular on those hosts).
- Prefer a NONCE- or HASH-based, or STRICT-DYNAMIC policy.
- Add a report-uri/report-to endpoint and watch it.
4. Trusted Types (Chromium): make the DOM XSS sinks refuse plain
strings -> forces all sink writes through a vetted policy.
Content-Security-Policy: require-trusted-types-for 'script'
Practice (40 min) — lab only
1. Juice Shop / PortSwigger:
- Reflected XSS: find a parameter echoed into the page. Pop
alert(document.domain). Then escalate to reading a cookie or
making an authenticated request in the console.
- Stored XSS: get a payload into a field that's rendered to other
users (a review, a name). Confirm it fires on page load.
- DOM XSS: find JS that writes location.hash into innerHTML.
Craft the URL.
2. Fix locally:
- 15-line app that reflects ?name= into HTML via string concat.
Confirm <script>alert(1)</script> runs.
- Add context-aware encoding (or use a templating engine with
autoescape). Confirm the payload now renders as visible text.
- Add a strict nonce-based CSP. In devtools, watch an inline
<script> without the nonce get BLOCKED, and see the violation
report.
3. Add a "bio" field that allows <b>/<i>. Implement it with DOMPurify
(allowlist b,i,em,strong only). Try to sneak <img onerror> past it.
4. Score your headers: run the fixed app through securityheaders.com
(local via ngrok, or read the curl -I output against the checklist).
Common confusions
- "I escape
<and>so I'm safe." Only for HTML body context. In an attribute without quotes, or in ajavascript:/href, or inside a<script>, or in CSS — different rules. Encode for the context, or don't put data there. - "CSP fixes XSS." CSP mitigates it.
unsafe-inlineor a sloppy host allowlist neuters it. Encoding is still the fix; CSP is the seatbelt. - "My framework auto-escapes, so no XSS." True until someone uses
dangerouslySetInnerHTML/v-html/bypassSecurityTrust, or builds ajavascript:URL, or does client-side templating of user data. - "HttpOnly cookies make XSS low severity." No. XSS = full control of the session in-browser regardless of cookie theft.
- "A regex strips
<script>." Trivially bypassed (<img onerror>,<svg onload>, mixed case, nested, null bytes). Use DOMPurify.
Check yourself
- Name the three XSS types and the key difference of each.
- With HttpOnly session cookies, what can an XSS still do? (List four.)
- What is "context-aware output encoding" and why isn't one encoding enough?
- What makes a CSP weak? Name two things that must NOT be in
script-src. - How should you handle a field where users legitimately submit some HTML?
Further reading
- Cheat sheets: OWASP "Cross Site Scripting Prevention", "DOM based XSS Prevention", "Content Security Policy".
- Tool: DOMPurify (
github.com/cure53/DOMPurify) — read the README's threat model. - Course: PortSwigger Academy — XSS track (huge, excellent) and the "Content Security Policy" material.
- Talk: "CSP: A Successful Mess Between Hardening and Mitigation" (Lukas Weichselbaum, Google) — why strict/nonce CSP beats allowlists.
- Site: Google's
csp-evaluator.withgoogle.com— paste your policy, see the bypasses.