Demystifying CORS, CSRF tokens, SameSite & Clickjacking - Web Security

Published 1/25/2021

This article is part of a series:


One of the best features of the web is its backwards compatibility. But ironically, this also makes the web somewhat insecure by default.

Understanding the different techniques and attack vectors can be quite complex. While the internet is filled with a lot of correct info, it's also filled with a lot of sparse, outdated, incorrect, or partial information.

Before we dive in

This series tackles security on the web, specifically, the browser. CORS, CSRF tokens, SameSite, clickjacking, httpOnly & secure cookies, XSS, CSP, http://, and all the questions that might come with it: Does SameSite=Lax eliminate CSRF tokens and/or CORS?, Do React/Vue/etc. really protect you from all XSS attack vectors? Do I still need to worry about JSON hijacking? Can I use CSRF tokens with a SPA? etc 🤯

💡 Please let me know through my email if I either missed something, made a mistake, or simply if this helped you better understand this big topic.

In this post, let's cover CORS, CSRF tokens, SameSite, clickjacking, and JSON hijacking.

Let's dive in

For the scenarios, let's consider that you develop banking software. We focus on two endpoints:

  • GET /accounts -> to list a users' accounts
  • POST /transfer -> to transfer money

For the user to use your software, he has to sign in by the use of cookies.

The whole security dilemma stems from one of your users being tricked to access a phishing site from an attacker.

Why is this dangerous? Because as of 2021, most modern browsers usually still send all cookies along with a request. Even in a third-party context like a phishing site (For ajax requests, when withCredentials is set to true). So, since the user has cookies on your bank site, a request to your bank will also send along those cookies that are used to identify them.

🤔 On the phishing site, can the attacker put malicious JavaScript to both get your accounts (GET request), as well as transfer money (POST request), both using a simple AJAX request?

The answer is: No! Unless the bank server explicitly allows it.

Introducing SOP and CORS

SOP, or Same-Origin Policy is a browser security feature which prevents AJAX requests in a third-party context. But sometimes, we do want to allow exactly that (e.g. SPA app <> API server). And that's what CORS, or Cross-Origin Resource Sharing is for. By setting various HTTP headers on the server.

🤔 How does SOP protect the user from AJAX requests?

Let's first cover the HTTP method GET:

SOP will prevent the attacker from accessing the response of the request in JavaScript. Additionally, you will see a cross-origin request violation error in the console.

Note however that whatever the bank server does for the GET route will actually be executed! This is because the browser doesn't know the HTTP (SOP/CORS) headers until it gets the response.

This is usually not a problem as in GET requests, all you do is get information. You should not perform any destructive actions in a GET request like deleting a database entry.

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

🤔 What about POST, PUT, DELETE and PATCH requests? These are all destructive actions, do I need to worry?

Not really! In those requests, the browser will first do a so-called preflight request using the HTTP method OPTIONS. This will not execute any code but will return the CORS headers. The browser will then judge if it is safe to send the real request or not.

Be aware that under certain circumstances, the browser will not send a preflight request first. For example, if multipart/form-data is used as the Content-Type. So make sure your server doesn't ignore this and just processes every content type as JSON. For an exhaustive list of rules, check here. Note how even a GET request with the right headers set could require a preflight request first.

Note that this is only an issue because the cookie in the browser gets sent along. So if an attacker tries to send a CURL request to your bank from a server script, where SOP doesn't apply, he can't do much here. (as long as he doesn't have your cookie..., more on that another time)


But an attacker may have other tricks up his sleeve, even with your site having no CORS enabled.

Instead of performing an API request they can put a <form /> on their phishing site with the action pointing to your site and submit it automatically. This will navigate the current browsing context (browser tab, iframe, popup, etc.) to your site (means, it will literally take you to your site in the browser), sending the cookies along with it. That's, of course, problematic for destructive actions, such as the money transfer endpoint at our bank.

This is called Cross-Site Request Forgery (CSRF), and since it's not an AJAX request but works through changing the browsing context, SOP will not protect you from it.

Introducing CSRF tokens

This is not an in-built browser feature, but a common solution for this problem.

It works like this: Every <form /> on the bank has to include a CSRF token like this:

<form action="..." method="POST">
  <input type="hidden" name="_csrf" value="C4N-U_R34D+T#15?">
  <button>Submit</button>
</form>

Of course, this token is not just hardcoded but changes every time you refresh the page.

Using frameworks, the token is usually added to the <form /> like this:

<form action="..." method="POST">
  {{ csrfField() }}
  <button>Submit</button>
</form>

Now when the attacker puts the <form /> on his phishing site, he won't have the CSRF token, so the form submission will always fail with a 403 forbidden.

🤔 You might be asking, couldn't the attacker have a server script (to circumvent SOP) to fetch the contents of any of the bank's site with a <form />, extract the CSRF token from the HTML, put it in his evil form and submit it just fine?

The answer is: No! That's because CSRF tokens are bound to the users' session. So your CSRF tokens won't work for me.

In case you are not using a framework and you implement CSRF tokens using a standalone library, you have to make sure you integrate them correctly to prevent the above.

🤔 I only communicate to my server through an API, not through the browser's <form /> element. Do I need to be careful?

Yes, because the attacker can still put a <form /> on his evil site with an action pointing to your API endpoint. Whether you use APIs or not is not important.

TLDR;

  • CSRF tokens are a common, custom solution to prevent CSRF
  • They are coupled with the users' session
  • They also prevent POST, PUT, PATCH and DELETE AJAX requests, but usually not GET requests.

To put it bluntly, it kind of sucks that we have to implement CSRF tokens on every website. While my explanation (hopefully :D) was simple, in practice there are a few complications like SPAs, token expiry, etc.

Wouldn't it be great if browsers just wouldn't send cookies when the request, AJAX or not, is in a third-party context?

Introducing SameSite

SameSite is a cookie attribute with which you can specify when a cookie should be sent along with a request.

It can be set to:

  • None: The cookies will always be sent no matter the context. This only works for cookies with the "secure" flag
  • Lax: The cookie will not be sent for AJAX requests in a third-party context, as well as top-level navigations (
    requests) using the POST method. This is what we want!
  • Strict: Same as Lax, but the cookie will also not be sent for top-level navigations using the GET method

This sounds very good, doesn't it! And the good news is that browsers have already started to make SameSite=Lax the default option, giving the web more security out of the box. Hopefully, all vendors will implement this soon.

If you are confused about "Strict"..., it's really super strict. It means if you click a link on site A to site B (where you are logged in), the cookie won't be sent along and you won't be logged in on site B (You would have to refresh or click any link on site B to appear logged in again).

So, let's tackle a few common questions!

🤔 What is considered "same" site?

Basically the apex domain (the TLD and the part before it). So if you have http://client.bank.com and https://www.api.bank.com, it still counts as "same-site".

🤔 Wait, so what about sites like GitHub pages? It's all under github.io...

There is a public suffix list to fix those.

🤔 CORS stands for "cross-origin...". What's the difference between "origin" and "site"?

We already covered the meaning of "site". "origin" is a lot stricter. Both sites need to have the same scheme(HTTP/HTTPS), port, and subdomain.

More info here: https://web.dev/same-site-same-origin/

🤔 Do we still need CSRF tokens with SameSite=Lax/Strict?

It depends. SameSite is a rather new feature and is not in legacy browsers or even older versions of modern browsers. If you can allow it, block the use of legacy browsers for your website.

🤔 Do we still need to be protective about SOP?

If you support not up-to-date browsers: Yes! If not: Probably..., but why risk it?

SameSite prevents cookies from being sent in a third-party context. So if you disable SOP by enabling CORS, an attacker can send a cookie-free AJAX request in the browser, the same way they could send a CURL request from a server script. Sounds like we don't need SOP anymore right?

Let's not forget one thing: The web doesn't just work using cookies. A site could also deliver different info based on other factors like your IP address for example. Or maybe(?) the browser could return a cached result that actually contains sensitive data.

I just don't see any benefit in risking it. Unlike CSRF token, which comes with a certain complexity, SOP is enabled by default.

TLDR;

  • The cookie attribute SameSite=Lax prevents cookies being sent in a third-party context (both AJAX requests and top-level navigation POST requests) making CSRF tokens obsolete
  • It's not supported in legacy browsers
  • Going forward, SameSite=Lax will be the default if not explicitly set to something else

Okay, so we can prevent ajax requests & <form /> submissions in a third-party context. But, what if the attacker goes a different route.

Instead of submitting a form, they:

  • load the bank site in an iframe
  • make it invisible through various CSS rules
  • absolutely position a button over the iframe on top of a form submit button inside the iframe.
  • give the button the CSS rule "pointer-events: none", so the click gets propagated to the element beneath it (the iframe)

Now clicking the button from the attacker will submit the form on the invisible iframe :/

Clickjacking

This attack is called clickjacking, and you can get really creative with it, like making a user think he's playing "whack-a-mole" while actually, he's navigating a shopping site for you.

How to prevent clickjacking?

If SameSite is set correctly, then the cookies will not be passed in a third-party context, even for iframes. So if the site authenticates using cookies, this makes it much safer.

But the attacker could still trick you to first sign in, and then do the other stuff...

You can prevent your page from being embeddable via iframes by setting the following HTTP-header:

X-Frame-Options: DENY

or with CSP's frame-ancestors.

But sometimes, you want your page to be embeddable anywhere like a social-media like-button.

Until recently, the only secure way to do this was to not use iframes and use a link that opens a popup instead.

But with modern JavaScript, you can detect if your site is 100% visible or not, even in an iframe, using the intersection observer.

To see in action how the intersection observer works and more details about this topic, I highly recommend watching this video on the topic: https://www.youtube.com/watch?v=EIH6IQgwdAc

TLDR;

  • Clickjacking is an attack that tricks people into unwantedly clicking on a site inside an (invisible) iframe
  • You can prevent your site from being embeddable via iframes by setting the header "X-Frame-Options" to "DENY"
  • For modern browsers, you can use the intersection observer to make sure your site (in an iframe) is really visible

Now, before finishing up this post, there is one more attack vector to be aware of, and that is JSON hijacking.

JSON hijacking

This is not a problem in modern browsers, but it's a problem that could surface again with new additions to JavaScript.

It basically allowed you to circumvent SOP in the browser by loading an API through the <script> tag (where SOP doesn't apply), sending along the cookies if SameSite doesn't apply.

Usually this just executes the script. But by overwriting Array you were able to listen to native array constructions. So if an array was returned, you could read the contents.

That's why Facebook starts all their API requests with an infinite for loop. So the browser never gets to execute the array portion.

There are some more interesting bits to the story, you can find more details about it here.


Next time, let's tackle the glaring question if CSRF tokens can be used in a SPA or not.