CSRF stories

I think I first learned about CSRF at an "@media" conference talk in 2008 given by Simon Willison (Twitter). In the middle of a Django presentation, he explained how it's possible to make a form that will post across to a different website, and that when you do so, the appropriate login details are also attached. This lets one website do anything it likes with the user's credentials on another website. "This," he explained, "should make you think 'oh shit'".

"Oh shit" I thought.

The solution to this problem is a csrf "token". The idea is that you add a new field to all the forms on your website. The field is prefilled with the token: something that only your own website would know.

So Simon's website has an evil form that posts { text: "I like marmite" }.
But all the forms on your website post { token: 'secret', text: "I like vegemite" }.
And you check the token before you accept the data.

This works perfectly, and it's left to the developers to figure out a way to generate, distribute and check the secret.

At Twitter when I joined we were using Ruby-on-Rails. It had a common library providing the form token, which I think it called "form_authenticity_token". However, the project we were working on was the "Follow button": a button that we wanted people to place on a good percentage of the entire internet's web pages. The traffic would be immense, and our Ruby-on-Rails system couldn't handle the load of serving those pages with their tokens. Our solution should have been Scala, as is well known, but at the time our first Scala service wasn't ready for prime-time, so we only had Rails to work with.

So we generated the token on the client. None of us had tried that before, so we were a little dubious, but we ran it past our security team who seemed satisfied by the idea. For clientside CSRF, we just generate a random string using JavaScript and append it to both the form AND the cookie. While Simon's evil form can submit any form fields it likes, it's unable to set or read cookies from another site. This worked well for numerous years.

More recently our security team moved our primary website off csrf tokens and onto Origin headers. These were more recently supported in browsers, and let you know the original domain of a request. If the origin is "twitter.com" then we can be pretty sure the request came from Twitter.

This worked well until recently, when we received a bug report that the csrf protection was failing. This was hard to believe - nothing had changed for years in that code, and all of our focus was on the new website.

Sure enough, the new website was the cause of the problem. The new website installs a serviceworker that intercepts requests, which means that it can cache pages for offline or performance reasons. Sure enough, it was intercepting Simon's evil form post and proxying it back to the server. Since it intercepted the request and issued a new one, it added the origin header for the serviceworker itself, completely defeating our old website's origin header csrf protection. The solution was simple: check the mode of the request before proxying it.

I've recently been playing with CSRF again for the new website. The new website depends on a CSRF cookie which is provided by the server. If the CSRF cookie expires (it has a limited duration) the API requests will fail, but they will issue a new cookie as they do so, and our code would retry the request. Something I'd noticed is that the retried requests would often still fail, which should be impossible.

Cookies are inherently complicated to handle. While the concept is simple, the api in the browser is not. There are volumes I could write on the inadequacies of the model. Happily, there are some beginnings of an idea to improve them, but it will likely take years to roll out. A lot of the problems stem from legitimate attempts at privacy protection: a browser or extension might hide or block a cookie it considers unworthy. Sadly this can interfere with security protections like CSRF, and there's no way for developers to detect or override them.

After a few failed attempts to crack the problem without success, I turned back to clientside token generation, something I hadn't looked at for 6 or 7 years. Is it still a valid option? I found that I could easily generate a random string that would be accepted by our serverside CSRF validation. In code review, my colleagues asked whether a Math.random-based solution would be sufficiently secure? My initial answer was that it shouldn't matter - this isn't a login credential, it's just a random string. Upon investigation I discovered this confidence was misplaced - in several browsers it's possible to reconstruct the state machine of the random number generator, and by asking for a few random numbers you can perfectly duplicate the machine to be able to predict the next number to be generated. Mind blown.

I switched my random string generator over to window.crypto, a new feature for generating cryptographically secure numbers. In production, this seems to work. When I find the cookie is missing or expired, I simply generate a new token and cookie and the requests succeed. While I struggled to reproduce the original retry-failure, I could mock up responses using Charles proxy, and observed the drop in errors when my code was deployed.

There was a spike in new errors, seemingly only from Xiaomi phones and tablets - they don't support window.crypto. I now let them fall back to the original retry logic. We'll still see some errors, but significantly fewer than before.

If you find a CSRF issue on the site, please do report it responsibly. The best place is hackerone.com/twitter, where there's also the chance of rewards and recognition.

Thanks for reading! I guess you could now share this post on TikTok or something. That'd be cool.
Or if you had any comments, you could find me on Threads.

Published