Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Ed Bartram and Anne Porosoff
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Ed Bartram@edbartram ) and Anne Porosoff@AnnePorosoff )

Cross-Site Request Forgery (CSRF / XSRF) Race Condition In AngularJS

By Ben Nadel on

At InVision App, I love looking through the errors logs. To me, every error log item represents an opportunity - an invitation to improve the app, fix bugs, harden the security, and create an all-around better user experience (UX). At InVision, we use the core AngularJS XSRF (Cross-Site Request Forgery) protection. But, as I've been digging through the logs, I've occasionally noticed an error in which both the incoming XSRF-TOKEN cookie and the AngularJS-provided X-XSRF-TOKEN header exist but do not match. Since I assume that the code that implements this logic in AngularJS is solid, I attribute the mismatch to a race-condition. And, I wanted to see if I could replicate the race condition locally.


 
 
 

 
 
 
 
 

The AngularJS XSRF protection is pretty straightforward. AngularJS will look for a cookie called "XSRF-TOKEN"; and, if found, it will append an "X-XSRF-TOKEN" header, with the same value, to all outgoing $http requests. On the server-side, this header value can then be compared to the cookie value in order to validate the origin of the request.

Now, keep in mind, AngularJS only has control over which headers are sent - it does not control which cookies are sent - the browser security manages that. And, if you recall that the $http request is implemented as a promise chain, it's easy to imagine that the act of making a request isn't necessarily a synchronous one. So, it might be possible that the cookie values change after the header value has been defined but before the underlying AJAX request has been initiated. This would leave wiggle-room for the two values to fall out of sync.

To try and find this wiggle-room, I've put together a small demo that hammers a server-side API with a request every 100 milliseconds. The API, written in ColdFusion, compares the incoming cookie value and the header value; and, if they match, the server code sends back a Set-Cookie header to cycle the XSRF cookie value. The hope here is that we hit a timing edge-case in which a response with an updated Set-Cookie header returns after a parallel request has examined the cookie but before the parallel request has actually been pushed over the wire.

Here's the ColdFusion API:

  • <!---
  • AngularJS uses very particular, case-sensitive names for the XSRF tokens.
  • It's going to be looking for an all-caps "XSRF-TOKEN" cookie and then appending
  • an all-caps "X-XSRF-TOKEN" header value.
  • --->
  • <cfset cookieName = "XSRF-TOKEN" />
  • <cfset headerName = "X-XSRF-TOKEN" />
  •  
  •  
  • <!---
  • If the cookie does not yet exist, set it and exit out of the request. This is
  • just here to initialize the demo. Once this is set, it won't be unset.
  • --->
  • <cfif ! structKeyExists( cookie, cookieName )>
  •  
  • <!--- Define the XSRF token cookie. --->
  • <cfset cookie[ cookieName ] = left( hash( getTickCount() ), 6 ) />
  •  
  • <cfheader statuscode="200" statustext="OK" />
  • <cfexit />
  •  
  • </cfif>
  •  
  •  
  • <cfset headers = getHttpRequestData().headers />
  •  
  • <!---
  • If there is no XSRF-TOKEN header, reject the response. This should never happen
  • in this demo; but, I just wanted it here to ensure I don't get any false negatives
  • in the XSRF-TOKEN match.
  • --->
  • <cfif ! structKeyExists( headers, headerName )>
  •  
  • <cfheader statuscode="400" statustext="Bad Request" />
  • <cfexit />
  •  
  • </cfif>
  •  
  •  
  • <!--- If the cookie and header values match, this is a safe request. --->
  • <cfif ( headers[ headerName ] eq cookie[ cookieName ] )>
  •  
  • <!--- Simulate some network and processing latency. --->
  • <cfset sleep( randRange( 50, 100 ) ) />
  •  
  • <!---
  • After each valid request, let's cycle the XSRF token. While we wouldn't
  • do this very often in an production application, we are trying to find an
  • edge-case. As such, cycling the cookie on each valid request will help us
  • find the race-condition.
  • --->
  • <cfset cookie[ cookieName ] = left( hash( getTickCount() ), 6 ) />
  •  
  • <cfheader statuscode="200" statustext="OK" />
  •  
  • <!--- If the values did NOT match, this request is unauthorized. --->
  • <cfelse>
  •  
  • <cfheader statuscode="401" statustext="Unauthorized" />
  •  
  • <cfoutput>#headers[ headerName ]# != #cookie[ cookieName ]#</cfoutput>
  •  
  • </cfif>

As you can see, every time the two values match, we cycle the XSRF-COOKIE.

WARNING: I am most definitely not suggesting that you should be cycling your XSRF token all the time. Frankly, doing so makes no sense. And, in modern thick-client applications, with loads of parallel requests, it seems downright dangerous (ala the race condition). I'm just doing it here as an experiment.

And, here's the AngularJS code that is sitting there hammering the API:

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Cross-Site Request Forgery (CSRF / XSRF) Race Condition In AngularJS
  • </title>
  • </head>
  • <body>
  •  
  • <h1>
  • Cross-Site Request Forgery (CSRF / XSRF) Race Condition In AngularJS
  • </h1>
  •  
  • <p>
  • <em>Hammering the API (like a boss) - see console logging.</em>
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo and run some code!
  • var app = angular.module( "Demo", [] ).run(
  • function run( $interval, $http, $log ) {
  •  
  • // Start hammering the request to try to find the race-condition.
  • $interval( makeApiRequest, 100 );
  •  
  •  
  • // I make a request to the API and log the response.
  • function makeApiRequest() {
  •  
  • // When AngularJS makes this request, it's going to look for the
  • // "XSRF-TOKEN" cookie; and, if it finds it, it's going to append
  • // the "X-XSRF-TOKEN" header, with the same value, to the outgoing
  • // HTTP request.
  • $http.get( "./api/index.cfm" ).then(
  • function handleResolve( response ) {
  •  
  • $log.log( "Good!" );
  •  
  • },
  • function handleReject( response ) {
  •  
  • $log.log( "!! Unauthorized !!", response.data );
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

When we run this code, and let the AngularJS application hammer the server, it works in the vast majority of cases. But, every now and then, it does fail:


 
 
 

 
 XSRF-TOKEN race condition in AngularJS. 
 
 
 

As you can see, the large counts next to "Good" indicate that most AJAX requests in the test work properly, sending identical XSRF-TOKEN cookies and X-XSRF-TOKEN headers. But, occasionally, and under extremely unusual test conditions, the two values do get out of sync, resulting in an unauthorized request.

In production, this happens so rarely that I'm not ready to worry about it. But, it's good to know that there is a race-condition in how XSRF-TOKEN cookies are translated into X-XSRF-TOKEN headers in AngularJS (and probably any other application framework that implements such technology). At least it can help explain some of our log item entires. The next step for me is to locate the few places that we cycle the XSRF cookie and determine if doing so is actually necessary.




Reader Comments

.promise is most likely bugging our for older browsers, all though a good way to lock it down.
@ben,

Try a POST, to a .cfc that has a generic method name try.cfc?method=sitePost, then include the actual method to execute as it would be picked up in your sitePost(catch all method) code.

Make it work like a proxy, AJAX is your friend here.

This what we use, not with ANJS.

$.ajaxSetup({
headers: {
"X-Requested-With" : "XMLHttpRequest",
accept: "application/json",
mt: "getcontent",
next_request: $('.some-nonvisiable-element').text()
}
});

$('.some-nonvisiable-element').text() is #request.next_request_id#, give it a thought.

Reply to this Comment

@Alex,

Hmm, looking through the AngularJS source code, I'm not actually sure that there is an event-tick between the calculation of the XSRF token and the initiating of the AJAX request. I wonder if, internally to the browser, the XHR method call:

xhr.send()

... is actually waiting a tick or something before sending. Definitely a funky edge-case.

Reply to this Comment

I don't think there is any race condition going on inside Angular (in this particular case at least :P). As @ben said, the code between reading the cookie and calling `xhr.send()` is all synchronous.

I'd say it's a rather typical network race condition. I suspect here's what'g going on:

1. Current cookie: `cookie1`
2. Browser send request with: `cookie1`
3. Server receives request, verifies cookie successfully and sends response (with updated cookie: `cookie2`)
4. Before the response get to the browser (where the cookie is still `cookie1`), a new request is sent with cookie: `cookie1`
5. The response is received by the browser and the cookie updated to `cookie2` (sorry, too late).
6. The second request (with cookie `cookie1`) reaches the server where the cookie is `cookie2` and the verification fails.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.