When deciding how to secure a Web Api there are a few choices available, for example you can choose to use JWT tokens or with a little bit less effort (but with other trade-offs), cookies.
If you decide to go with cookies and if your web api is consumed through a web application (e.g. Angular) it will be vulnerable to cross-site request forgery attacks (frequently referred to as CSRF or XSRF).
You can find the sample project for this blog post here in github.
What is CSRF and how it can be mitigated
To understand how CSRF works you need to understand how browsers handle cookies.
Every time you visit a website (e.g. mywebsite.com) and that website sends a response with a header named Set-Cookie
a cookie is created. An example of a header that creates a cookie named myCookie
with the value myCookieValue
is: Set-Header: myCookie=myCookieValue
.
Usually cookies are stored in a file on disk. This actually depends on the browser you are using but that’s essentially all they are. That is also the reason why you can log in to the same website with two different accounts if you use different browsers. Your “identity” is stored in a cookie and each browser can hold a different one for the same website.
Also, a cookie is specific to one website. That means that a cookie for example.com
is not valid for foo.com
. This boils down to the browser exclusively sending cookies to example.com
that were created in a response to a request to example.com
.
The way the browser “sends” the cookies is by including a header named Cookie
whenever there is a request to the website from which the cookie originated.
For example if the browser has a cookie for example.com
and you have a bookmark for example.com/homepage
and you click it, the browser will automatically include a cookie header with the cookie value (e.g.: Cookie: myCookie=myValue
). There are exceptions to this namely the use of the SameSite policy. However this feature is still “experimental” and for the use case of allowing your web api to be potentially used by anyone, using a SameSite policy would not work.
The abuse of this mechanism (i.e. the browser sending the cookies automatically) is what CSRF exploits.
Here’s a simple example. Imagine your website has an endpoint at example.com/logout
that responds to GET requests (which is a bad idea to begin with, but bear with me). Picture another website, named evilwebsite.com
, that has an image element like this:
<img src="www.example.com/logout">
Just by visiting evilwebsite.com
you’re logged out of example.com
. This works because when the browser parses the html for evilwebsite.com
and finds the img
tag, it will perform a request to example.com/logout
which will, for all intents and purposes, look like any other authenticated request from the point of view of example.com
.
If you have a very popular website, or if your attacker has a way to target users of your website s/he can try to lure your users to a malicious website that can then perform requests to example.com
as you.
Imagine if your bank website had this vulnerability. A malicious website could trigger transfers of money from your account to another account.
That’s really scary but thankfully it’s not too hard to mitigate. And we’ll even use cookies to mitigate it, here’s how.
CSRF mitigation
We’ve already seen that the browser will send the cookies it has for a website automatically. If any of those cookies is used to identify the user that cookie is usually set as an httponly
cookie.
An httponly
cookie is a cookie that is created using the httponly
directive, for example:
Set-Cookie: AuthCookie=1Wkc5dGNtRnVaRzl0Y21GdVpHOXQ=; HttpOnly
This makes the cookie unavailable through JavaScript, i.e. if you run document.cookie
that cookie won’t be visible.
It’s important that cookies that identify the user are httponly
so that in case of a Cross-Site Scripting vulnerability (XSS) the attacker won’t be able to steal the auth cookie.
Now that we’ve established that we can create httponly
cookies, let’s explore the fact that non-httponly
are accessible via JavaScript.
Image that you’ve created a non httponly
cookie with a random number, e.g. AntiForgeryCookie=42289347
and when you perform a request to the website, you read the cookie value in JavaScript and put it in a header in the request, e.g. AntiForgeryHeader: 42289347
.
When handling the request in the server you check if the cookie and the header values are the same. If they aren’t, or they are missing, the request is rejected.
This breaks things from the point of view of the attacker.
When an attacker triggers requests to a website where a user is logged in to, the auth cookie and the anti-forgery cookie are both sent but the attacker has no way to access them. It’s not possible to read the anti-forgery cookie’s value and put it in the header of that request (JavaScript running in evilwebsite.com cannot access your website).
In a nutshell that’s how CSRF is mitigated.
Applying CSRF mitigations in a Web Api built using ASP.NET Core
The out of the box functionality provided in ASP.NET Core for mitigating CSRF (named anti forgery) is geared towards Razor views.
You’ve probably seen it in the form of the @Html.AntiforgeryToken()
html helper in previous versions of MVC (pre Core 2.0).
When you use the @Html.AntiforgeryToken()
html helper in a Razor view a cookie is created alongside a hidden form field named __RequestVerificationToken
. The value of both must match or else the request is rejected.
Thankfully the anti forgery features in ASP.NET Core are configurable enough that we can use them for a Web Api.
The first thing we have to do is to register the anti forgery dependencies and configure it so that instead of expecting a form field on POST requests, it expects a header. We can pick a name for our header, for example X-XSRF-TOKEN
.
Here’s how that looks like in Startup.cs
‘s ConfigureServices
method:
public void ConfigureServices(IServiceCollection services)
{
services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
});
//...
Specifying a HeaderName
when configuring AntiForgery causes the anti forgery validation to use the header (instead of a form field named __RequestVerificationToken
) in the verification process.
You can also specify the cookie name you wish to use (e.g. options.Cookie.Name="MyAntiForgeryCookieName"
). This isn’t the cookie we’ll access through JavasScript though, so there’s no real advantage in doing this.
Now, for the part of actually generating the anti forgery cookies I recommend doing that in a controller action that you call immediately after a successful login. This might seem odd, however for the sake of brevity I’ll defer the reasoning behind this choice for later in this post.
Here’s how a controller action for generating the anti forgery cookies looks like:
[ApiController]
public class AntiForgeryController : Controller
{
private IAntiforgery _antiForgery;
public AntiForgeryController(IAntiforgery antiForgery)
{
_antiForgery = antiForgery;
}
[Route("api/antiforgery")]
[IgnoreAntiforgeryToken]
public IActionResult GenerateAntiForgeryTokens()
{
var tokens = _antiForgery.GetAndStoreTokens(HttpContext);
Response.Cookies.Append("XSRF-REQUEST-TOKEN", tokens.RequestToken, new Microsoft.AspNetCore.Http.CookieOptions
{
HttpOnly = false
});
return NoContent();
}
}
First, we grab hold of the IAntiforgery
service as a dependency and we use its GetAndStoreTokens
method to actually generate the tokens/cookie values.
The GetAndStoreTokens
method not only creates the cookie and the request tokens, it modifies the response so that the Set-Cookie
statement is added to it (that’s why it needs HttpContext
as an argument).
The Cookie and Request tokens are the terms used to refer to the two values that must match. The Cookie token is the one stored in the cookie and the Request token is either sent in a hidden form field __RequestVerificationToken
or in a header value like we will do shortly.
You might be wondering why we need a Cookie and Request token if we only need one value to match (the value form the cookie and the value from the header) to mitigate CSRF. The reason for this is that the RequestToken contains the logged in username (HttpContext.User.Identity.Name
) as well as a random value that matches the value stored in the cookie.
Here’s the discussion around this on github.
This is also the reason why I recommend using Antiforgery
this way, in a separate request after the user logs in.
If we were to generate the anti forgery tokens in the response to the login request, the Request token that would be created would not contain a Username
(HttpContext.User.Identity.Name
isn’t set yet at that time). Any subsequent requests that requires anti forgery validation would fail. That’s because even though the value in both anti forgery tokens would match, the username in the request token (empty) would not match HttpContext.User.Identity.Name
.
Going back to the code, the next line is the non-httponly
cookie that we will read using JavaScript and put in the requests we perform to the server:
Response.Cookies.Append("XSRF-REQUEST-TOKEN", tokens.RequestToken, new Microsoft.AspNetCore.Http.CookieOptions{
HttpOnly = false
});
I’ve named it XSRF-REQUEST-TOKEN
. This name has no effect on any Web Api code, so you can name it whatever you like. We’ll only access its value in JavaScript.
One final note about this controller action. It’s annotated with the IgnoreAntiforgeryToken
attribute. This is one of the three attributes available in ASP.NET Core for dealing with CSRF, the other two are AutoValidateAntiforgery
and ValidateAntiforgery
.
ValidateAntiforgery
will validate every request, whereas AutoValidateAntiforgery
will only perform validation for unsafe HTTP methods (methods other than GET, HEAD, OPTIONS and TRACE).
These last two attributes are usually applied as global filters. In this case I recommend that you use the ValidateAntiforgery
attribute since we want all requests to be validated.
The reason for this is that if we are relying on Cookies as our means to authenticate users, and we want consumers of our api to potentially come form any origin, we need a CORS configuration that is very permissive (see Secure an ASP.NET Core Web Api using Cookies).
With such a permissive configuration it is possible to forge GET requests from another domain and steal sensitive information, therefore they should also be checked for CSRF.
Here’s how we can configure all controller actions to be checked for CSRF:
public void ConfigureServices(IServiceCollection services)
{
//...
services.AddMvc(options =>
{
options.Filters.Add(new ValidateAntiForgeryTokenAttribute());
});
//...
We need to annotate some controller actions with IgnoreAntiforgeryToken
. Namely the one that handles the user’s login, the one we’ve seen above that handles the anti forgery tokens’ generation and any others that you might want to make available without requiring the user being authenticated.
The client
In the client we need to make a request to the endpoint where the anti forgery tokens are generated after the user has logged in successfully.
Since the example project was done in Angular, where’s how that looks like in Angular:
//...
export class AccountService {
constructor(private httpClient: HttpClient) { }
//...
login(email: string, password: string) {
return this.httpClient.post(`${environment.apiBaseUrl}/api/account/login`, {
email,
password
}).pipe(
switchMap(_ => this.httpClient.get(`${environment.apiBaseUrl}/api/antiforgery`))
);
}
This just performs two http request, one POST to api/account/login
with the user’s credentials and a GET request to /api/antiforgery
after the first request finishes successfuly.
We also need to read the cookie that is non-httponly
(XSRF-REQUEST-TOKEN
) and put it in a header named X-XSRF-TOKEN
(that’s the name we used when configuring Antiforgery in Startup.cs
) when the client performs a request.
We could do that manually for every request, but that wouldn’t be very practical. Thankfully there are ways to have it be done automatically for every request (the beforeSend
function in jQuery’s $.ajax for example).
If you are using Angular there’s an out of the box module HttpClientXsrfModule for that. Unfortunately, it does not work for cross domain requests. So what we’ll do here is add an http interceptor that reads the cookie and adds its value to outgoing requests as a header. Here’s how that looks like:
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable()
export class AddCsrfHeaderInterceptorService implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
var requestToken = this.getCookieValue("XSRF-REQUEST-TOKEN");
return next.handle(req.clone({
headers: req.headers.set("X-XSRF-TOKEN", requestToken)
}));
}
private getCookieValue(cookieName: string) {
const allCookies = decodeURIComponent(document.cookie).split("; ");
for (let i = 0; i < allCookies.length; i++) {
const cookie = allCookies[i];
if (cookie.startsWith(cookieName + "=")){
return cookie.substring(cookieName.length + 1);
}
}
return "";
}
}
An http interceptor is a normal service but needs to be registered in a module using a "multi" provider (you can have several of them), here's how that looks like:
@NgModule({
//...
providers: [
//...
{ provide: HTTP_INTERCEPTORS, useClass: AddCsrfHeaderInterceptorService, multi: true }
]
//...
That's it, your api is now safe form CSRF attacks.
If you had some issues with the example or have questions please write them down in the comments and I'll get back to you as soon as I can.