Cookies like to get around. They have no scruples about where they go save for some basic constraints relating to the origin from which they were set. I mean have a think about it:
If a website sets a cookie then you click a link to another page on that same site, will the cookie be automatically sent with the request? Yes.
What if an attacker sends you a link to that same website in a malicious email and you click that link, will the cookie be sent? Also yes.
Last one: what if an attacker directs you to a malicious website and upon visiting it your browser makes a post request to the original website that set the cookie - will that cookie still be sent with the request? Yes!
Cookies just don't care about how the request was initiated nor from which origin, all they care about is that they're valid for the requested resource. "Origin" is a key word here too; those last two examples above are "cross-origin" requests in that they were initiated from origins other than the original website that set the cookie. Problem is, that opens up a rather nasty attack vector we know as Cross Site Request Forgery or CSRF. Way back in 2010 I was writing about this as part of the OWASP Top 10 for ASP.NET series and a near decade on, it's still a problem. Imagine this request:
POST https://hack-yourself-first.com/Account/ChangePassword Cookie: AuthCookie=EF29... NewPassword: passw0rd ConfirmPassword: passw0rd
This is a real request from my Hack Yourself First website I use as part of the workshops Scott Helme and I run. You can go and create an account there then try to change the password and watch the request that's sent via your browser's dev tools. Then, ask yourself the question: what does the HTTP request need to look like in order to change the user's password? There are only 3 requirements:
- It needs to be a POST request
- It needs to be sent to the URL on the first line
- It needs to have 2 fields in the body called NewPassword and ConfirmPassword
That is all. It doesn't matter if the request is initiated from the website itself or from an external location, for example an attacker's website. If that malicious site can force the browser into making a POST request to that URL with that form data, the password is changed. Why is this possible? Because the auth cookie is sent with the request regardless of where it's initiated from and that, is how a CSRF attack works.
We (the industry) tackled this risk by applying copious amounts of sticky tape we refer to anti-forgery tokens. By way of example, here's what the request to perform a domain search for troyhunt.com on HIBP looks like:
POST https://haveibeenpwned.com/DomainSearch .AspNet.ApplicationCookie=BjzGJ4... __RequestVerificationToken=B0kTW2... DomainName: troyhunt.com __RequestVerificationToken: Llx764...
There are two anti-forgery tokens passed in the request, one in a cookie and one in the body, both called "__RequestVerificationToken". They're not identical but they're paired such that when the server receives the request it checks to see if both values exist and belong together. If not, the request is rejected. This works because whilst the one in the cookie will be automatically sent with the request regardless of its origin, in a forged request scenario the one in the body would need to be provided by the attacker and they have no idea what the value should be. The browser's security model ensures there's no way of the attacker causing the victim's browser to visit the target site, generate the token in the HTML then pull it out of the browser in a way the malicious actor can access. At least not without a cross site scripting vulnerability as well and then that's a whole different class of vulnerability with different defences.
This, frankly, is a mess. Whilst it's relatively easy to implement via frameworks such as ASP.NET, it leaves you wondering - do cookies really need to be that promiscuous? Do they need to accompany every single request regardless of the origin? No, they don't, which is why if you look in Chrome's dev tools on this very blog at the time of writing, you'll see the following:
The "future release of Chrome" is version 80 and it's scheduled to land on the 4th of Feb which is rapidly approaching. Which brings us to the SameSite cookies mentioned in the console warning above. In a nutshell, they boil down to 3 different ways of handling cookies based on the value set:
- None: what Chrome defaults to today without a SameSite value set
- Lax: some limits on sending cookies on a cross-origin request
- Strict: tight limits on sending cookies on a cross-origin request
Come version 80, any cookie without a SameSite attribute will be treated as "Lax" by Chrome. This is really important to understand because put simply, it'll very likely break a bunch of stuff. In order to demonstrate that, I've set up a little demo site to show how "Lax" and "Strict" SameSite cookies behave alongside the traditional ones with no policy at all. I'm going to do this with an "origin" site (the one that sets the cookies in the first place) and an "external" site (one which links to or embeds content from the origin site). Let's begin by visiting the origin site at http://originsite.azurewebsites.net/setcookies/
That's intentionally loaded over the insecure scheme for reasons that will became apparent later. It sets a bunch of cookies which can then be read at http://originsite.azurewebsites.net/readcookies/
I'm showing the Chrome dev tools here as they make it easy to see the SameSite value that's been set for each cookie (if set at all). These have been given self-explanatory names so no need to delve into them here. The main thing is that the site setting the cookies can read them all. But that's not what SameSite is all about, let's make it interesting and load up http://externalsite.azurewebsites.net/
There are 4 different things I want to demonstrate here as each implements a slightly different behaviour. Let's begin with by clicking the GET request button:
This loads the origin website with the GET verb and passes through all existing cookies except for the "Strict" one. Going back to the purpose of this blog post, once Chrome starts defaulting cookies without a SameSite policy to "Lax", GET requests will still send them through.
Next up, let's try the POST request:
And this is where things start to get interesting as neither the "Strict" nor "Lax" cookies have been sent with the request. The default cookies with no SameSite policy has, but only because I'm running Chrome 79. Come next month when Chrome 80 hits, the image above will no longer show the default cookie. By extension, any websites you're responsible for that are passing cookies around cross domain by POST request and don't already have a SameSite policy are going to start misbehaving pretty quickly.
Next up is the iframe:
You can see how the source of the frame is the origin website and embedding it like this will make a GET request, but even the "Lax" cookie hasn't been passed. This is really important to understand: not all resource types behave the same way even when the same verb is used.
The last one is the cookie image and it's easiest just to look at the request in the dev tools for this one:
As with the iframe, it's only the cookies with no SameSite policy that are sent either because it's explicitly set to "None" or because no policy has been set at all. But as with the iframe and the POST request, the default cookie shortly won't be sent at all and again, that's where the gotcha is going to hit next month.
Now, there's two more nuance to all this, the first of which is detailed on Chrome's Platform Status page:
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.
Given that last sentence, it's probably not something you want to be relying on though.
The second nuance relates to cookies with a "None" policy that are served insecurely:
As a massive HTTPS proponent, this makes me happy 😊 To demonstrate this behaviour, I've added an additional "None" cookie but flagged it as secure. As such, the cookie will only stick after being loaded over an HTTPS connection so give this a go: https://originsite.azurewebsites.net/setcookies/
That results in the following cookies coming back in the response, the highlighted one being the new one:
Now try loading the secure version of the external site at https://externalsite.azurewebsites.net/
Both the highlighted cookies will die as of Chrome 80: The "None" cookie because whilst it has a SameSite policy, it's not flagged as "Secure" and the default cookie because it will inherit the behaviour of a "Lax" cookie which will no longer be loaded into an iframe.
Want to try it all out? You can toggle these features in Chrome today by first changing the default behaviour for cookies without a SameSite policy at chrome://flags/#same-site-by-default-cookies
And then requiring that they be secure at chrome://flags/#cookies-without-same-site-must-be-secure
Now let's try loading that last page again:
This change has the potential to break a lot of stuff so if you're in an environment where you're explicitly disabling the SameSite policy with "None" and still making insecure requests (*cough* enterprise), times are about to get interesting. Or if you're Google's own tracking service:
This popped up on my blog as soon as I changed Chrome's default behaviour to reflect what's coming next month (it's subtly different to the one earlier in this blog post) so it's a good example of the sorts of things you can proactively pick up now. If you do see this sort of thing in the enterprise, Chrome's changed behaviour can also be reverted across the organisation:
Enterprise IT administrators may need to implement special policies to temporarily revert Chrome Browser to legacy behavior if some services such as single sign-on or internal applications are not ready for the February launch.
As an example of the sort of stuff this change impacts, Microsoft have said it breaks OpenIdConnect logins which is definitely something you want to be aware of in advance. If you're living in a Microsoft world, as of .NET 4.7.2 there's now a SameSite enum on cookies to help make configuring your apps a little easier:
(Quick note on Microsoft's implementation: their first shot at it was buggy and caused the "None" policy to omit the SameSite cookie attribute altogether which, as you're now aware, would cause issues come Chrome 80. It's since fixed in the latest framework releases but as of the time of writing, hasn't been pushed to the Azure App Service. I've just finished a rather frustrating debugging process which culminated in manually sending a "Set-Cookie" header to make the demo app behave as I wanted it to.)
This changed cookie behaviour looks like it's going to stick and extend well beyond just Chrome. There's an IETF draft for Incrementally Better Cookies specifying "Lax" by default and requiring "Secure" on all "None" cookies and Mozilla have an intent to implement the same behaviour in Firefox (note that back in May they were targeting V69 but as of V71 which is current today, it's not yet implemented).