Web API is a powerful tool for constructing web services, but also for separating concerns of a web application. However, like any web application, security concerns must be addressed. One of the most common security concerns with authenticated operations is cross-site request forgery. In a traditional ASP.NET MVC application, we can use the built-in AntiForgeryToken mechanism to place a unique token within each page served to the client, and require that token be included in any requests back to the server. Since an attacker cannot access the contents of the actual served page, they are unable to acquire the token; and since the token is not a cookie or credential, the browser will not send it automatically. Thus, an attacking page cannot craft an effective CSRF attack.
When moving into the Web API world, things are a little more muddy. The general tack has been to take a different angle on authentication and authorization all together. For example, the use of certificates to sign each request, bearer tokens, oAuth, or similar approaches. However, for purely intranet applications, we may still wish to use Windows authentication. This means the end-user of a website which calls our API will automatically do so with the user’s windows credential. (It is also possible for server-side applications to use impersonation to call the API with a particular Windows service account, however, this is not subject to CSRF vulnerability).
There are also ways to make a joint MVC/Web API application use the MVC anti-forgery tokens, which is really neat, but only works if the API and application are one cohesive web application solution, as otherwise the Web API’s instance of ASP.NET would differ from the MVC instance of ASP.NET, and they would not share the set of valid anti-forgery tokens. It also doesn’t work if “plain” HTML/JS is being used to access the API.
However, by using Windows authentication, we open our API up to CSRF attackers. An intranet user may be using a web application which uses our API (and thus, carries their credential), while at the same time they are browsing evilattacksites.com which can construct a form with the intranet URL and post it to the API’s intranet address. This post will carry the user’s credentials with it, and execute a successful attack.
You might note that the API is on a intranet site and the attacker is (probably) external, so CORS might be suggested as an answer. However, CORS only controls what data can be read, it does not protect against CSRF submissions.
We want Web API CSRF protection that:
- Works with any client (doesn’t require pages generated by MVC)
- Works with Windows authentication/intranet
- Is easy to “bolt-on” to existing Web API services AND clients
Closing the CSRF Vulnerability
The solution we will use is to provide a per-IP CSRF token that must be attached to the HTTP header and is validated on all POST/PUT/DELETE requests.
The technique here is to construct a message handler which will process all Web API requests before they go to the controller. It will validate the presence of a CSRF token when needed, and produce it when requested. Thus, there will be no changes to the controllers! The only server-side application change (besides importing the message handler), is to add it in the WebApiConfig Register method.
config.MessageHandlers.Add(new Infrastructure.CSRFMessageHandler());
On the client side, none of the individual requests to the API need to be altered. An initial login call (handled by the CSRFMessageHandler on the server) acquires the CSRF token and places it into the headers for all subsequent ajax calls. There will be no other changes needed to the client. However, clients which consume multiple Web API services secured in this way will have a more complex setup procedure. 🙂
$(function () { $.ajax("../api/login") .done(function (data) { $.ajaxSetup({ headers: { "X-CSRF-Key": data } }) }) .fail(function () { /* DO SOMETHING */ }); });
With no further changes, the API is now secure, and the clients receive and use the CSRF token to gain access. This protection can easily be “bolted on” to existing APIs and clients.
Implementation of CSRFMessageHandler
The real magic of this technique is in CSRFMessageHandler. This handler intercepts each call to the Web API before it is sent to the controller. There are two parts: the CSRF token verification, and the token generation.
First, the overall class structure of the handler. We prepare a RNGCryptoServiceProvider for generating the token, and extract the request context so we have access to the ASP.NET application state object, where the CSRF tokens will be stored (you could also store them in a database, or other repository).
public class CSRFMessageHandler : DelegatingHandler { private static readonly RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider(); public const string CSRF_HEADER_NAME = "X-CSRF-Key"; protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var method = request.Method.Method.Trim().ToUpper(); // Extract the context from the request property // so that application "state" can be accessed var context = ((HttpContextBase)request. Properties["MS_HttpContext"]); // PART ONE: On "login" request, create token. // Put this here so no need to modify controller. ... see below // PART TWO: For update methods, enforce the CSRF key ... see below return await base.SendAsync(request, cancellationToken); } }
When the client sends a “login” request (any request ending in /login), it will be intercepted by the handler and a CSRF token will be created and returned. If desired, you could check the dictionary to see if the client already has a key and reuse it. You could send back the username along with the key. You could set an expiration time for the key and make a process to remove old (expired) keys. Some additional improvement is definitely possible.
if (method == "GET" && request.RequestUri.AbsolutePath.ToUpper().EndsWith("/LOGIN")) { var keys = context.Application[CSRF_HEADER_NAME] as IDictionary<string, string>; if (keys == null) { keys = new Dictionary<string, string>(); context.Application[CSRF_HEADER_NAME] = keys; } byte[] bkey = new byte[16]; rng.GetBytes(bkey); var key = Convert.ToBase64String(bkey); keys[context.Request.UserHostAddress] = key; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(key) }; }
For all modification requests (POST, PUT, DELETE), the handler will validate that the appropriate key is included in the headers.
if (method == "POST" || method == "PUT" || method == "DELETE") { HttpResponseMessage response = request.CreateErrorResponse( HttpStatusCode.Forbidden, "POST/PUT/DELETE require valid and matching anti-CSRF key, use Login method"); string key = null; if (request.Headers.Contains(CSRF_HEADER_NAME)) key = request.Headers.GetValues(CSRF_HEADER_NAME).Single(); var keys = context.Application[CSRF_HEADER_NAME] as IDictionary<string, string>; string ipaddr = context.Request.UserHostAddress; // match to the key for user's IP address if (keys == null || !keys.ContainsKey(ipaddr) || keys[ipaddr] != key) throw new HttpResponseException(response); }