Please read the original article as it contains various fixes!
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 in the comments if I either missed something or made a mistake.
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 still send all cookies along with a request. Even in a third-party context like a phishing site. 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 CORS
CORS, or Cross-Origin Resource Sharing, is a browser security feature which prevents AJAX requests in a third-party context.
The option can be tweaked by setting various HTTP headers since sometimes you really want to allow certain third parties to communicate with the server (e.g. SPA app <> API server). So if the bank accidentally set the wrong CORS headers, it can be a big problem.
π€ How does CORS protect the user from AJAX requests?
Let's first cover the HTTP method GET:
CORS will prevent the attacker from accessing the response of the request in JavaScript. Additionally, you will see a CORS 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 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.
π€ What about POST, PUT, DELETE and PATCH requests? These are all destructive actions, do I need to worry?
No! In those requests, the browser will first do a so-called preflight request using the HTTP method OPTION
. 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.
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 CORS doesn't apply, he can't do much here. (as long as he doesn't have your cookie..., more on that another time)
TLDR;
- CORS prevents AJAX requests in a third-party context
- For GET requests, it does so by disallowing JavaScript to read the response, but your server code for the route still has to run first
- For other HTTP verbs, it sends a preflight request
OPTION
first to avoid running any destructive action on your server.
But an attacker may have other tricks up his sleeve.
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 perform top-level navigation 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 top-level navigation, CORS 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 CORS) 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.
π€ Do I still need CORS now that I have CSRF tokens?
Yes! While the attacker would also need your CSRF token to do an AJAX request, he won't need it to perform GET requests to get your accounts for example. Technically you could protect GET requests with a token, but usually, you want your user to just access /accounts
, bookmark it and open it later again, without any token in the URL that expires after a short while.
π€ 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 which is done through the
<form />
element & top-level navigation instead of AJAX - They are coupled with the users' session
- They also prevent POST, PUT, PATCH and DELETE AJAX requests, but usually not GET requests. Keep on using CORS!
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 CORS?
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 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 CORS 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, CORS is just an HTTP header, enabled by default.
TLDR;
- The cookie attribute
SameSite=Lax
prevents cookies being sent in a third-party context (both API 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
But sometimes, you want your page to be embeddable 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 CORS in the browser by loading an API through the <script>
tag (where CORS 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.
Top comments (1)
Learned so many things.
Thanks