The limits of the same-origin policy: cross-origin (but same-site) attacks

same-site, cross-origin attacks

Here’s an interesting brain teaser: Is it safe to host untrusted HTML on evil.example.com if your main app is hosted on app.example.com? The tempting simple answer is “it is safe as long as authentication cookies are set for app.example.com and not .example.com”, but there are a number of other key ways this can go wrong. Read on!

Overwriting cookies

While it isn’t possible for evil.example.com to read cookies defined for app.example.com, it is possible to write cookies. What this means is that evil.example.com can set cookies that will be included in the app’s requests, and it can even override the cookies that your app sets (since cookies with a more specific path have a higher precedence). There are three common ways this can go wrong and lead to a vulnerability affecting app.example.com:

  1. Session Fixation (and login XSRF) a. An attacker can override your app’s authentication cookies. At worst, this can be used for session fixation by setting a known session cookie, and then tricking the user into logging into their account so that the known session cookie is associated with their account. This trick doesn’t work in most modern frameworks, but what does work is using this for a login-XSRF-like attack. An attacker can log in the victim to the attacker’s account, and then exploit a self-XSS in order to attack the victim.
  2. CSRF Bypass for double-submit cookies a. A common way to defend against CSRF attacks is a so-called “double submit” cookie. The idea is that a random value is included in all state changing requests as both a cookie and part of the request body. And the server just checks that the two values match. Since an attacker doesn’t know the cookie value, they can’t include it in the request body, and thus can’t pull off a CSRF attack. But, in this scenario the attacker has the ability to overwrite cookies. So an attacker can overwrite the double-submit cookie value, and then include it in their own request body, and pull off a CSRF attack.
  3. DoS a. Lastly, an attacker can break the victim’s ability to interact with the app by setting an excessively long cookie value. Most load balancers and frameworks will reject requests with really long cookies, so if the attacker sets a long cookie for .example.com then the victim will be unable to use the app until they clear all their cookies.

CSRF Bypass via SameSite cookies

Modern web browsers support the SameSite attribute on cookies. Julian Cretel has a great explanation of SameSite cookies (and why they aren’t called SameOrigin cookies) that is worth a full read.  But the high level summary is that SameSite cookies are included on all same-site requests. And in this case, evil.example.com and app.example.com are same-site. So if your app relies on SameSite cookies to defend against CSRF, it is completely vulnerable with this set up.

The specter of Spectre

Back in 2018, Spectre was all the rage. In the context of the web, Spectre allowed JS to read the memory of anything in the same process as the JS interpreter. At the time, there was a hope that Spectre was fixable, but ultimately browsers have had to admit that this isn’t fixable, and the only mitigation is to ensure that sensitive data doesn’t end up in the same process as the JS interpreter. Browsers achieve this by allocating a dedicated process per-site. But this means that evil.example.com and app.example.com are placed in the same process, so the JS running on evil.example.com can actually read anything displayed on app.example.com! Admittedly, this is tricky to pull off, but it is possible

Password Managers

Many password managers (including the default ones built into Chrome, Firefox, and Safari) scope stored passwords to the site. This means if the user has stored a password for login.example.com, most password managers will autofill the password for evil.example.com.

Commonly misconfigured CORS

The final category here isn’t unfixable, but it is common. CORS allows an endpoint to expose itself to cross-origin pages. This is done by checking whether the Origin header in requests is trusted. In a perfect world, this would always be done with an explicit allowlist like request.headers['origin'] in {"https://app.example.com", "https://admin.example.com"}. But oftentimes engineers want to avoid having to maintain the allowlist, so they do something like urlparse(request.headers['origin']).hostname.endsWith(".example.com"). And if you do this, then evil.example.com is allowlisted, and can access your potentially sensitive resources via CORS. 

How to do this right?

The classic solution to this problem is to allocate a separate domain (e.g. exampleusercontent.com) and use that to host all your untrusted active content. But, there still is a risk there because you want to ensure that separate pieces of untrusted content (e.g. user1’s evil HTML and user2’s innocent HTML) are isolated from each other. So you can allocate each user their own subdomain (e.g. user1.exampleusercontent.com) to ensure they’re cross-origin. That still leaves user1 and user2 same-site though, which can be fixed by adding exampleusercontent.com to the public-suffix list. This will make it so that user1.exampleusercontent.com and user2.exampleusercontent.com are cross-site, and thus receive the highest possible level of separation.