Simon Willison’s Weblog

Subscribe

Exploring the SameSite cookie attribute for preventing CSRF

3rd August 2021

In reading Yan Zhu’s excellent write-up of the JSON CSRF vulnerability she found in OkCupid one thing puzzled me: I was under the impression that browsers these days default to treating cookies as SameSite=Lax, so I would expect attacks like the one Yan described not to work in modern browsers.

This lead me down a rabbit hole of exploring how SameSite actually works, including building an interactive SameSite cookie exploration tool along the way. Here’s what I learned.

Background: Cross-Site Request Forgery

I’ve been tracking CSRF (Cross-Site Request Forgery) on this blog since 2005(!)

A quick review: let’s say you have a page in your application that allows a user to delete their account, at https://www.example.com/delete-my-account. The user has to be signed in with a cookie in order to activate that feature.

If you created that page to respond to GET requests, I as an evil person could create a page at https://www.evil.com/force-you-to-delete-your-account that does this:

<img src="https://www.example.com/delete-my-account">

If I can get you to visit my page, I can force you to delete your account!

But you’re smarter than that, and you know that GET requests should be idempotent. You implement your endpoint to require a POST request instead.

Turns out I can still force-delete accounts, if I can trick a user into visiting a page with the following evil HTML on it:

<form action="https://www.example.com/delete-my-account" method="POST">
<input type="submit" value="Delete my account">
</form>
<script>document.forms[0].submit()</script>

The form submits with JavaScript the instant they load the page!

CSRF is an extremely common and nasty vulnerability—especially since it’s a hole by default: if you don’t know what CSRF is, you likely have it in your application.

Traditionally the solution has been to use CSRF tokens—hidden form fields which “prove” that the user came from a form on your own site, and not a form hosted somewhere else. OWASP call this the Double Submit Cookie pattern.

Web frameworks like Django implement CSRF protection for you. I built asgi-csrf to help add CSRF token protection to ASGI applications.

Clearly it would be better if we didn’t have to worry about CSRF at all.

As far as I can tell, work on specifying the SameSite cookie attribute started in June 2016. The idea was to add an additional attribute to cookies that specifies the policy for if they should be included in requests made to a domain from pages hosted on another domain.

Today, all modern browsers support SameSite. MDN has SameSite documentation, but a summary is:

  • SameSite=None—the cookie is sent in “all contexts”—more-or-less how things used to work before SameSite was invented. Update: One major edge-case here is that Safari apparently ignores None if the “Prevent cross-site tracking” privacy preference is turned on—and since that is on by default, this means that SameSite=None is effectively useless if you care about Safari or Mobile Safari users.
  • SameSite=Strict—the cookie is only sent for requests that originate on the same domain. Even arriving on the site from an off-site link will not see the cookie, unless you subsequently refresh the page or navigate within the site.
  • SameSite=Lax—cookie is sent if you navigate to the site through following a link from another domain but not if you submit a form. This is generally what you want to protect against CSRF attacks!

The attribute is specified by the server in a set-cookie header that looks like this:

set-cookie: lax-demo=3473; Path=/; SameSite=lax

Why not habitually use SameSite=Strict? Because then if someone follows a link to your site their first request will be treated as if they are not signed in at all. That’s bad!

So explicitly setting a cookie with SameSite=Lax should be enough to protect your application from CSRF vulnerabilities... provided your users have a browser that supports it.

(Can I Use reports 93.95% global support for the attribute—not quite high enough for me to stop habitually using CSRF tokens, but we’re getting there.)

What if the SameSite attribute is missing?

Here’s where things get interesting. If a cookie is set without a SameSite attribute at all, how should the browser treat it?

Over the past year, all of the major browsers have been changing their default behaviour. The goal is for a missing SameSite attribute to be treated as if it was SameSite=Lax—providing CSRF protection by default.

I have found it infuriatingly difficult to track down if and when this change has been made:

  • Chrome/Chromium offer the best documentation—they claim to have ramped up the new default to 100% of users in August 2020. WebViews in Android still have the old default behaviour, which is scheduled to be fixed in Android 12 (not yet released).
  • Firefox have a blog entry from August 2020 which says “Starting with Firefox 79 (June 2020), we rolled it out to 50% of the Firefox Beta user base”—but I’ve not been able to find any subsequent updates.
  • I have no idea at all what’s going on with Safari!

I started a Twitter thread to try and collect more information, so please reply there if you know what’s going on in more detail.

The Chrome 2-minute twist

Assuming all of the above, the mystery remained: how did Yan’s exploit fail to be prevented by browsers?

After some back-and-forth about this on Twitter Yan proposed that the answer may be this detail, tucked away on the Chrome Platform Status page for Feature: Cookies default to SameSite=Lax.

Note: Chrome will make an exception for cookies set without a SameSite attribute less than 2 minutes ago. Such cookies will also be sent with non-idempotent (e.g. POST) top-level cross-site requests despite normal SameSite=Lax cookies requiring top-level cross-site requests to have a safe (e.g. GET) HTTP method. Support for this intervention (“Lax + POST”) will be removed in the future.

It looks like OkCupid were setting their authentication cookie without a SameSite attribute... which opened them up to a form-based CSRF attack but only for the 120 seconds following the cookie being set!

Building a tool to explore SameSite browser behaviour

I was finding this all very confusing, so I built a tool.

A screenshot showing the two pages from the demo side-by-side

The code lives in simonw/samesite-lax-demo on GitHub, but the tool itself has two sides:

Hosting on two separate domains is critical for the tool to show what is going on. I chose Vercel and GitHub Pages because they are both trivial to set up to continuously deploy changes from a GitHub repository.

Using the tool in different browsers helps show exactly what is going on with regards to cross-domain cookies.

A few of the things I observed using the tool:

  • SameSite=Strict works as you would expect. It’s particularly interesting to follow the regular <a href=...> link from the static site to the application and see how the strict cookie is NOT visible upon arrival—but becomes visible when you refresh that page.
  • I included a dynamically generated SVG in a <img src="/cookies.svg"> image tag, which shows the cookies (using SVG <text>) that are visible to the request. That image shows all four types of cookie when embedded on the Vercel domain, but when embedded on the GitHub pages domain it differs wildly:
    • Firefox 89 shows both the SameSite=None and the missing SameSite cookies
    • Chrome 92 shows just the SameSite=None cookie
    • Safari 14.0 shows no cookies at all!
  • Chrome won’t let you set a SameSite=None cookie without including the Secure attribute.
  • I also added some JavaScript that makes a cross-domain fetch(..., {credentials: "include"}) call against a /cookies.json endpoint. This didn’t send any cookies at all until I added server-side headers access-control-allow-origin: https://simonw.github.io and access-control-allow-credentials: true. Having done that, I got the same results across the three browsers as for the <img test described above.

Safari ignoring SameSite=None looked like it was this bug: Cookies with SameSite=None or SameSite=invalid treated as Strict—it’s marked as fixed but it’s not clear to me if the fix has been released yet—I still saw that behaviour on my macOS 10.15.6 laptop or my iOS 14.7.1 iPhone.

Update: krinchan on Hacker News has an answer here:

The Safari “bug” is a new setting that’s turned on by default: “Prevent cross-site tracking”. It treats all cookies as SameSite=Lax, even cookies with SameSite=None.

Full Third-Party Cookie Blocking and More on the WebKit blog has more about this.

Most excitingly, I was able to replicate the Chrome two minute window bug using the tool! Each cookie has its value set to the timestamp when it was created, and I added code to display how many seconds ago the cookie was set. Here’s an animation showing how Chrome on a form submission navigation can see the cookie that was set with SameSite missing at 114 seconds old, but that cookie is no longer visible once it passes 120 seconds.

Animated demo of the tool in Chrome

Consider your subdomains

One last note about CSRF that you should consider: SameSite=Lax still allows form submissions from subdomains of your primary domain to carry their cookies.

This means that if you have a XSS vulnerability on one of your subdomains the security of your primary domain will be compromised.

Since it’s common for subdomains to host other applications that may have their own security concerns, ditching CSRF tokens for Lax cookies may not be a wise step!

Login CSRF and SameSite=Lax

Login CSRF is an interesting variety of CSRF with slightly different rules.

A Login CSRF attack is when a malicious forces a user to sign into an account controlled by the attacker. Why do this? Because if that user then saves sensitive information the attacker can see it.

Imagine I trick you into signing into an e-commerce account I control and saving your credit card details. I could then later sign in myself and buy things on your card!

Here’s how that would work: Say the site’s login form makes a POST to https://www.example.com/login with username and password as the form fields. If those credentials match, the site sets an authentication cookie.

I can set up my evil website with the following form:

<form action="https://www.example.com/login">
  <input type="hidden" name="username" value="my-username">
  <input type="hidden" name="password" value="my-password">
</form>
<script>document.forms[0].submit()</script>

I trick you into visiting my evil pge and you’re now signed in to that site using an account that I control. I cross my fingers and hope you don’t notice the “you are signed in as X” message in the UI.

An interesting thing about Login CSRF is that, since it involves setting a cookie but not sending a cookie, SameSite=Lax would seem to make no difference at all. You need to look to other mechanisms to protect against this attack.

But actually, you can use SameSite=Lax to prevent these. The trick is to only allow logins from users that are carrying at least one cookie which you have set in that way—since you know that those cookies could not have been sent if the user originated in a form on another site.

Another (potentially better) option: check the HTTP Origin header on the oncoming request.

Final recommendations

As an application developer, you should set all cookies with SameSite=Lax unless you have a very good reason not to. Most web frameworks do this by default now—Django shipped support for this in Django 2.1 in August 2018.

Do you still need CSRF tokens as well? I think so: I don’t like the idea of users who fire up an older browser (maybe borrowing an obsolete computer) being vulnerable to this attack, and I worry about the subdomain issue described above.

And if you work for a browser vendor, please make it easier to find information on what the default behaviour is and when it was shipped!