Keeping our tokens in cookies can have significant advantages over using the Web Storage API. If we use the HttpOnly attribute, we can’t access the cookies through JavaScript. That means that any malicious code won’t be able to do that too.
In the above scenario, we assume that the browser automatically sends the cookies when it performs requests. No need to attach the token manually. This mechanism has its caveats, though. We depend on the browser to send the cookies. Therefore, it might do that in inappropriate moments.
In this article, we explore what a Cross-Site Request Forgery (CSRF) is and how we can decrease its impact by managing our cookies better.
What a Cross-Site Request Forgery attack is
With Cross-Site Request Forgery (CSRF), an attacker forces the victim to perform unintentional actions. If the victim is currently authenticated on a website, the consequences can be severe. Let’s go through a straightforward example of the CSRF attack.
Imagine receiving a message from your friend with a link. You open it, not knowing that the account of your friend has been hacked. The malicious site that you visit can attempt a CSRF attack, hoping that your browser still has the cookies from the time you used your online banking account.
Unfortunately, the attacker can find a way to send a request from a malicious website to the bank and steal your funds. That is, if your bank has security flaws, that won’t stop such attacks.
Let’s say that the attacker figured out the exact URL in the bank’s API that transfers the funds from one account to the other. The hostile website wouldn’t use the Fetch API or XHR to send an Ajax request because it wouldn’t contain your cookies. After all, they belong to the origin of the bank.
A possible thing to do would be to redirect the victim from the infected site to the bank.
1 |
window.location.replace('https://your-bank.com/api/send-money'); |
Running the above code would result in sending a GET request to the provided URL. Depending on the configuration of the cookies, the browser might attach them to the request. It would be a simple Cross-Site Request Forgery attack by forcing the user to perform an unwanted action.
Sending requests other than HTTP GET
Usually, the GET requests don’t have much impact on the application. Their job is just to return the data. Unfortunately, the attacker can forge a POST request without using the XHR or Fetch API.
To do that, the malicious site needs to contain a form element.
1 2 3 4 5 6 7 8 |
<form id="malicious-form" action="https://your-bank.com/api/send-money" method="post" > <input hidden="true" type="text" name="amountOfMoney" value="10000"> <button>Hack me!</button> </form> |
Clicking on the “Hack me” button redirects the user to the https://your-bank.com/api/send-money page with a POST request. It also appends the amountOfMoney field as form data. Depending on the configuration of the cookies, it can also append them. This might wreak havoc if not for the two-factor authentication. The two-factor authentication could be bypassed, though.
The attacker has a few ways of improving the above code, so beware. The first thing would be sending the form automatically, without the need to click on the “Hack me!” button.
1 |
document.querySelector('#malicious-form').submit(); |
Also, a lot of the APIs nowadays use JSON to communicate, instead of application/x-www-form-urlencoded. Fortunately, the attacker can’t mark the data as application/json. With hacking the form value a bit, the attacker can prepare a malicious text/plain request, though.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<form id="malicious-form" action="https://your-bank.com/api/send-money" method="post" enctype="text/plain" > <input hidden="true" type="text" name="{"amountOfMoney": "10000", "unnecessaryField":"" value="unnecessaryValue"}" > </form> |
If our server accepts it even though it is marked as text/plain instead of application/json, it is a potential issue.
Using SameSite cookies
In the previous section, we’ve often mentioned that whether the browser sends the cookies or not depends on the configuration. The attribute that can affect this behavior is called SameSite. It is a part of the Set-Cookie HTTP response header. It allows us to specify if the browser should send the cookies when the request is initiated from another domain.
If you want to know more about cookies and the Set-Cookie header, check out Cookies: explaining document.cookie and the Set-Cookie header
SameSite=None
If we set the SameSite=None attribute, the browser sends the cookies in all contexts. Here, the browser sends the cookies both with window.location.replace and the request we initiate through the <form> elements.
1 |
response.setHeader('Set-Cookie', `Authentication=${token}; HttpOnly; SameSite=None; Secure`); |
We refer to cookies matching the domain of the current site as the first-party cookies. We call cookies from domains other than the current site third-party cookies. If we use an iframe to embed our-website.com in another-site.com, the browser considers it a cross-site context. Since we’ve marked the cookies with the SameSite=None attribute, the browser sends them with each matching request.
Another case worth noting is that the browser also sends the cookies marked with SameSite=None when requesting images from another domain.
The SameSite=None attribute requires us to also set the Secure attribute. With it, we can only send cookies with through HTTPS
SameSite=Lax
When we send the SameSite=Lax attribute when setting the Set-Cookie header, the above behavior changes. With it, the browser does not send cookies with cross-site requests as long as the user is not navigating to the origin site.
1 |
response.setHeader('Set-Cookie', `Authentication=${token}; HttpOnly; SameSite=Lax; Secure`); |
If we have an iframe that embeds our-website.com in another-site.com, the browser does not send the SameSite=Lax cookies even if we were previously authenticated in the our-website.com. Since SameSite=Lax recently became the default value in modern browsers, it broke some solutions that we could have encountered on the web.
An attacker might still perform some of the attacks mentioned in this article. The browser sends SameSite=Lax cookies when navigating to other sites. Therefore, using window.location.replace still causes the browser to send cookies with a GET request. Therefore, it might not be a good idea to design our API so that GET requests could be malicious. A similar situation is with the <form> elements with method="get" and the <a> elements.
The browser also does not send the cookies with POST requests made with <form> elements or when requesting images.
SameSite=Strict
The SameSite=Strict is the most secure of all possible settings. With it, the browser sends the cookies only from a first-part context.
1 |
response.setHeader('Set-Cookie', `Authentication=${token}; HttpOnly; SameSite=Strict; Secure`); |
This means that the browser does not send the cookies when using window.location.replace. The same thing applies to anchor elements and GET requests we could send with the <form> element.
Also, browsers do not send cross-site requests from within iframes, similarly to cookies with SameSite=Lax.
Summary
In this article, we’ve looked into some examples of CSRF attacks and how we could counter them. We’ve seen all of the possible SameSite configurations and how they differ. It is also good to know that browsers recently shifted to using SameSite=Lax by default. Some applications became more secure out of the box, but some solutions might have broke.
Making sure that our application is secure is one of the crucial jobs of a developer. Therefore, it is definitely worth knowing how our application could be attacked and how to deal with it.
Love this article! Thanks a lot! I really appreciate the straight forward way of describing topics on your blog 😀