Cross-Site Request Forgery (CSRF / XSRF) Race Condition In AngularJS
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:
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:
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.
Want to use code from this post? Check out the license.
.promise is most likely bugging our for older browsers, all though a good way to lock it down.
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.
"X-Requested-With" : "XMLHttpRequest",
$('.some-nonvisiable-element').text() is #request.next_request_id#, give it a thought.
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:
... is actually waiting a tick or something before sending. Definitely a funky edge-case.
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.
Thanks for your article - we just stumbled across this problem in an angular/spring-boot application: sending put requests quickly resulted an CSRF problem.
We solved this by not regenerating the csrf token on every request but by generating the token from the authentication header (sha256 from the authentication). This has the effect that the csrf token is different for all users, for every login session of the same user and cannot be predicted by an attacker (if the authentication header could be stolen, CSRF is the smallest problem).