Appearance
8.5.2 — XSS, CSRF and SSRF
Three attacks, one shape.
In XSS, the browser is tricked into running the attacker's code as if it came from your site. In CSRF, the browser is tricked into sending a request the user did not intend, carrying credentials it attaches automatically. In SSRF, the server is tricked into making a request the attacker cannot make themselves.
All three are the confused deputy from Chapter 8.1: a component with privilege the attacker lacks is persuaded to use that privilege on their behalf. Chapter 6.10 covers the browser mechanisms — same-origin policy, CORS, CSP — from the front-end side. This page is the attacks and the server's defences.
1. Cross-site scripting
XSS is injection (Chapter 8.5.1) where the channel is an HTML page and the instructions are JavaScript.
Three kinds, distinguished by where the payload lives:
Stored — the payload is saved on the server and served to everyone. A comment, a profile name, a support ticket. The worst kind, because it needs no interaction and hits every viewer, including administrators viewing an admin panel.
Reflected — the payload is in the request and echoed into the response. A search term rendered into "no results for …". Needs the victim to follow a link.
DOM-based — the server never sees it. Client-side code reads location.hash and writes it into the page. Server-side defences and server-side scanners both miss this entirely.
What an attacker actually gets is the part people understate. The script runs with your site's origin, so it can: read the DOM including anything on screen, read localStorage (Chapter 8.4.2), make authenticated requests as the user and read the responses, replace the page with a fake login form, log keystrokes, and persist by writing itself into user content. HttpOnly cookies limit theft but not use — the attacker still acts as the user from inside their browser. "It only pops an alert box" describes a proof of concept, not the impact.
Defence: encode at output, in the right context
The rule is to encode when data enters a page, not when it enters the database. The same string is safe in one place and dangerous in another, so encoding at input means storing mangled data that is still wrong somewhere.
Five contexts, five different escapes:
| Context | Example | Escape |
|---|---|---|
| HTML body | <p>HERE</p> | & < > " ' to entities |
| Attribute value | <div title="HERE"> | Same, and always quote the attribute |
| JavaScript | var x = "HERE" | JavaScript string escaping — or do not do this |
| URL | <a href="HERE"> | URL encoding, and validate the scheme |
| CSS | style="width: HERE" | CSS escaping — avoid entirely |
An unquoted attribute is exploitable without any special characters: <div class=HERE> with the value x onmouseover=alert(1) needs no quotes or angle brackets at all.
Modern frameworks escape by default, which is why XSS is now concentrated in the escape hatches:
jsx
<div>{userInput}</div> // safe
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // ✗
<a href={userInput}>link</a> // ✗ javascript:alert(1)The same list per framework: Vue's v-html, Angular's [innerHTML] and bypassSecurityTrust*, template engines' raw-output markers. Grep for them; each one is a place where the framework stopped helping.
The href case is the one people miss. Frameworks escape text, and javascript:alert(1) contains nothing that needs escaping. Validate the scheme — allow https:, http:, mailto: and relative URLs, reject everything else.
When you genuinely must render user HTML — a rich text editor — sanitise with a maintained library (DOMPurify), not a regular expression. HTML parsing has enough edge cases that hand-written filters are bypassed reliably: mutation XSS abuses the fact that a browser re-parses the DOM and can turn apparently safe markup into an executable form after sanitisation.
Two upload cases worth knowing. An SVG is a document that can carry script, so an SVG served from your origin is stored XSS — serve user uploads from a separate origin, or with Content-Disposition: attachment and X-Content-Type-Options: nosniff. And Markdown renderers frequently permit raw HTML by default; disable it or sanitise the output.
CSP is the second layer (Chapter 6.10 covers it in depth): it does not prevent injection, it makes a successful injection much less useful — no inline script without a nonce, and connect-src preventing exfiltration.
2. Cross-site request forgery
The attacker cannot read your site's responses (the same-origin policy stops that), but the browser will happily send a request with your cookies attached. CSRF lives entirely in that asymmetry.
html
<!-- On attacker.example -->
<form action="https://bank.example.com/transfer" method="POST" id="f">
<input name="to" value="attacker"><input name="amount" value="5000">
</form>
<script>f.submit()</script>The victim visits the attacker's page while logged in to the bank. The browser sends the POST with the session cookie, because cookies are attached by destination, not by who initiated. The bank sees an authenticated request. The attacker never reads the response and does not need to — the transfer happened.
Defences, in the order to apply them
1. SameSite cookies. SameSite=Lax is now the default in major browsers and blocks cookies on cross-site POSTs, which removes most classic CSRF.
It is not complete, and the gaps matter:
Laxstill sends cookies on top-level GET navigation. So any state-changing endpoint reachable by GET is still exposed — which is one more reason GET must be safe (Chapter 5.6.1).- Some browsers apply a short grace period for newly set cookies.
- Older clients and non-browser agents may not enforce it.
Set SameSite=Strict where the flow allows it, and note the trade: a user following a link from an email arrives logged out.
2. A synchroniser token. The server issues a random token, embeds it in the form, and requires it on submission. The attacker cannot read it — that is the same-origin policy doing its job — so they cannot forge the request.
ts
// Issue: one token per session, in a non-HttpOnly cookie plus the form
res.cookie('csrf', token, { sameSite: 'lax', secure: true }); // (1)
// Verify: the header/body value must equal the cookie value
if (!timingSafeEqualStr(req.get('x-csrf-token') ?? '', req.cookies.csrf)) {
return res.sendStatus(403); // (2)
}(1) This is the double-submit cookie variant, which is stateless and therefore popular. (2) Compared in constant time (Chapter 8.2.2).
Double-submit has one real weakness: cookies are shared across subdomains. An attacker who controls or has XSS on blog.example.com can set a cookie for example.com and then knows the token they set. The fix is to sign the token to the session (a keyed HMAC over the session id) so a value the attacker chose does not validate.
3. For JSON APIs, require a custom header. A cross-site request with a custom header triggers a CORS preflight (Chapter 6.10), and the preflight will not be approved by your server. So X-Requested-With: XMLHttpRequest, or simply requiring Content-Type: application/json, blocks the form-submission attack — a form cannot send that content type. This is why many JSON APIs need no token at all.
4. Check Origin, falling back to Referer. Cheap and effective as a second layer; write it to fail closed when both are absent on a state-changing request.
Where CSRF does not apply: if the credential is a bearer token the client attaches deliberately, there is nothing for the browser to send automatically. CSRF is a cookie problem. Move to Authorization headers and it disappears — which is one of the genuine arguments for the backend-for-frontend pattern in Chapter 8.4.2, where the cookie exists but never leaves your own origin.
Two under-considered variants. Login CSRF logs the victim into the attacker's account so their subsequent activity is recorded there — so protect the login form too. Logout CSRF is minor and annoying, and is the reason logout should be a POST.
3. Server-side request forgery
Your server can reach things the attacker cannot: internal services, admin interfaces, databases, and — in the cloud — the metadata service. SSRF makes your server fetch a URL of the attacker's choosing.
ts
// "Import an image from a URL"
const img = await fetch(req.body.imageUrl); // ✗What an attacker reaches:
http://169.254.169.254/— the cloud instance metadata service, historically returning IAM credentials to any GET request. This is the highest-value target and the one to check first.- Internal services with no authentication, because "it is on the internal network".
http://localhost:…— admin ports, debug endpoints, unauthenticated dashboards.file:///etc/passwd, if the fetching library supports the scheme.- Port scanning, using response times and errors to map the internal network.
Blind SSRF still matters: even with no response body returned, timing differences map the network, and an out-of-band callback confirms the request was made.
Defence
1. Do not accept URLs if you can accept something narrower. An upload beats a URL fetch. A provider name plus an id beats a URL. This removes the class rather than mitigating it.
2. Allow-list, never block-list. Block-lists fail: 127.0.0.1 has 127.1, 2130706433 (decimal), 0x7f.0.0.1, [::1], [::ffff:127.0.0.1], and any attacker-controlled domain with an A record pointing at a private address.
3. Resolve the hostname, check the resolved IP, then connect to that IP. This is the essential subtlety — checking the hostname and then fetching separately is a time-of-check-to-time-of-use bug, because the attacker's DNS can return a public address on the first lookup and a private one on the second. That is DNS rebinding, and the fix is to pin the connection to the address you validated.
ts
const { address } = await dns.lookup(new URL(input).hostname); // (1)
if (isPrivate(address)) throw new Error('blocked'); // (2)
const res = await fetch(input, {
redirect: 'manual', // (3)
lookup: () => address, // (4)
});(1) Resolve once. (2) Reject loopback, link-local (169.254.0.0/16), private ranges (Chapter 5.3.1), IPv6 unique-local, and IPv4-mapped IPv6. (3) Follow redirects manually and re-validate each hop — an allowed URL that 302s to the metadata service defeats a check done only on the first URL. (4) Connect to the address you validated, not to whatever DNS says now.
4. Network-level controls are the strongest layer. Egress rules that deny the metadata address and internal ranges from application subnets mean an SSRF in any application, including a dependency you did not write, reaches nothing. On AWS, require IMDSv2, which needs a PUT to obtain a token first and sets a hop limit — a plain SSRF GET cannot satisfy it. This is a one-line configuration that eliminates the highest-value target.
5. Never return the raw response to the user, and never leak status codes or timing differences that reveal what was reachable.
And clickjacking, which belongs here as the other cross-site trick: your page is loaded in a transparent iframe over the attacker's, so the user's clicks land on your interface. Content-Security-Policy: frame-ancestors 'none' is the fix, with X-Frame-Options: DENY for older clients.
What the interviewer will push on
"What can an attacker actually do with XSS?" Anything the user can do, in the user's session: read the page, make authenticated requests and read the responses, replace the page with a fake login, persist by writing into stored content. HttpOnly prevents theft, not use. Answering with "steal the cookie" alone understates it by a lot.
"Where do you encode, and why not at input?" At output, in the context. The same string is safe in an HTML body and dangerous in an href, so encoding at input stores mangled data that is still wrong somewhere. Then name the five contexts and the two traps — unquoted attributes, and javascript: in an href that needs no escaping.
"How does CSRF work if the same-origin policy exists?" The policy stops reading responses, not sending requests, and cookies are attached by destination. That asymmetry is the whole vulnerability, and the attacker never needs the response.
"How do you defend against CSRF?" SameSite=Lax first — noting it still permits top-level GET navigation, so GET must be safe — then a synchroniser or signed double-submit token, and for JSON APIs a custom header or JSON content type that a form cannot produce. Then the framing that resolves it: CSRF is a cookie problem, and bearer tokens in headers remove it.
"What is the weakness of double-submit cookies?" Cookies are shared across subdomains, so an attacker controlling a subdomain can set the cookie and know the token. Sign the token to the session so a value they chose does not validate.
"How would you safely fetch a user-supplied URL?" Prefer not to. Otherwise: allow-list, resolve the hostname once and validate the IP, connect to that validated address to defeat DNS rebinding, follow redirects manually and re-validate each hop, never return the raw body — and back it with egress network rules and IMDSv2, so an SSRF anywhere in the estate reaches nothing.
One thing to volunteer: point out that requiring IMDSv2 is a one-line change that removes the highest-value SSRF target from every application on the instance, including third-party code you did not write. It is the clearest example in this chapter of a control that works at the layer where the failure is, rather than in each application that might have the bug.
Recall
- XSS, CSRF and SSRF are all the confused deputy: a component with privilege the attacker lacks is made to use it for them.
- XSS is injection into an HTML page. Stored is worst, DOM-based is invisible to server-side defences. It grants everything the user can do, and
HttpOnlystops theft but not use. - Encode at output, per context — five contexts, five escapes. Always quote attributes, and validate URL schemes, because
javascript:needs no escaping. Framework escape hatches (dangerouslySetInnerHTML,v-html,[innerHTML]) are where XSS now lives. Sanitise rich text with a maintained library; SVG uploads and raw HTML in Markdown are stored XSS. - CSRF exists because the same-origin policy blocks reading, not sending, and cookies attach by destination. Defences:
SameSite=Lax(still allows top-level GET), a synchroniser token, a signed double-submit (plain double-submit is defeated from a subdomain), or a custom header / JSON content type a form cannot produce. Bearer tokens in headers remove CSRF entirely. - SSRF targets the metadata service (
169.254.169.254), unauthenticated internal services andlocalhost. Blind SSRF still maps the network. - Defence: prefer a narrower input; allow-list; resolve once, validate the IP, connect to that address to beat DNS rebinding; re-validate every redirect hop; never return the raw body.
- Egress network rules and IMDSv2 are the strongest layer, because they protect applications you did not write.
- Clickjacking is stopped by
frame-ancestors 'none'.
Self-test: Why does encoding at input fail? · Which two output cases are dangerous with no special characters at all? · What exactly does the same-origin policy fail to prevent in CSRF? · How is a plain double-submit token defeated? · What is DNS rebinding, and which line of the fetch defends against it? · Why does IMDSv2 stop an SSRF that reaches the metadata address?
Next: 8.5.3 covers the layer in front of the application — security headers, what happens when you trust a proxy header you should not, request smuggling, and bot defence.