Reimplementing Rails’ CSRF protection in Sinatra
To hone my intuitions about Cross Site Request Forgery protection, I’ve been reimplementing Rails’ defence against t̶h̶e̶ ̶d̶a̶r̶k̶ ̶a̶r̶t̶s̶ CSRF in a Sinatra app I’ve been working on. If I can build something analogous to the real thing, that should help me see any blind spots more easily than just reading about Rails’ implementation, which happens ‘magically’ behind the scenes.
CSRF?
CSRF. If your app has an endpoint that’s listening for form submissions, then by default it can receive a request from anywhere and anybody. So we want to be sure that any form submitted has come from our own app: it’s authentic.
The basic outline of Rails’ defence mechanism is that, whenever a page is viewed, an authenticity token is created, which is then put in two places: the session cookie (which hangs around in the browser and gets sent with every request), and the HTML for the page. Then, whenever a non-GET request is made, the browser sends along the token from the HTML, and Rails verifies that it matches the one in the session cookie.
(In fact, the one in the HTML is a ‘masked’ version of the token, and there is a slightly complicated bit of cryptography that is out of scope here. Let’s cover up this bit of complexity by talking about ‘matching’ tokens rather than ‘identical’ tokens.)
My app
In the Before Epoch, when pensioners frolicked in the streets and strangers breathed down each others’ necks on the tube, I used to love a word game called Snatch. I’ve been trying to recreate the glory of this game online, here.
My app is rather silly. It’s a toy app that is never going to hold sensitive data. So let’s make one particular attack vector hyper-secure!
The way its form submissions work is not by using the usual HTTP request-response cycle, but by setting up a WebSockets connection (using Faye). All that means, as far as we are concerned here, is that the authenticity token is going to have to come through that route.
Before I started using an authenticity token, any player could make moves on behalf of other players by opening the developer console, and sending a WebSockets message to the server:
ws.send(JSON.stringify({
action: 'word',
room: '2',
handle: 'my arch nemesis',
word: 'floccinaucinihilipification' }));
This won’t do.
First, let’s enable sessions in app.rb
. This is a single-page-app, so the page is likely to be loaded only once per session.
module Snatch
class App < Sinatra::Base
enable :sessions
use Snatch::SnatchBackend
# ...
end
end
(The middleware SnatchBackend
, which handles the WebSockets connection to the clients as well as setting up a web, should be use
’d after enabling sessions, so that the session is available in the middleware.)
Next we’ll create the authenticity token and add it to the session. This will happen whenever we load the home page (the only page!). (I’m using a random number generator for this because it’s funny. You could use SecureRandom to get something that isn’t predictable if you have the seed and which is long enough to withstand brute-forcing attacks.)
get '/' do
create_authenticity_token
erb :"index.html"
end
private
def create_authenticity_token
@authenticity_token = rand().to_s
session[:authenticity_token] = @authenticity_token
end
Now let’s have the client submit this authenticity token with every message they send. Adapting the earlier attack for example:
ws.send(JSON.stringify({
authenticity_token: '0.123456789',
action: 'word',
room: '2',
handle: 'my arch nemesis',
word: 'floccinaucinihilipification' }));
And finally, in SnatchBackend
, we’ll verify (in authenticated?
) that the token received matches what’s in the session. Then we’ll either perform some game updates, or we’ll do nothing, depending.
module Snatch
class SnatchBackend
...
def call(env)
@session = Rack::Request.new(env).session
if Faye::WebSocket.websocket?(env)
ws = Faye::WebSocket.new(env, nil, { ping: KEEPALIVE_TIME })
...
ws.on :message do |event|
@data = JSON.parse(event.data).transform_values { |value| ERB::Util.html_escape(value) }
if authenticated?
initialize_room if @redis.hget(room_key, 'tiles').nil?
perform_any_actions
@redis.publish(CHANNEL, JSON.generate(data_to_publish))
end
end
...
end end
private
def authenticated?
@session['authenticity_token'] == data['authenticity_token']
end
...
end
end
Great news! Now no hackers can illegitimately submit words on your behalf unless they do something clever like stealing your computer.
That’s because they don’t know what authenticity token to submit from their browser. And you don’t know their token, either, so it’s fair enough really.
There we have it — the most secure-to-CSRF game of Snatch you’ll ever see!
It’s not exactly Rails
Among various differences between this hacked-together implementation and Rails, are:
- Aforementioned cryptography
- I’m not resending the session cookie with each request (I think?), because I’m not using HTTP. So the session data used in
SnatchBackend
is probably unchanged since the most recent page load.
But it’s basically analogous.
Questions to investigate further
Why does Rails store the authenticity token in the session cookie? Isn’t it easier to steal from there than from the session itself (on the server), since the session cookie lives on the user’s own computer (in the browser)? Perhaps this is related to the fiddly cryptography stuff. But if there is a way to calculate a ‘masked’ token (for the request) from the stolen cookie’s token, it seems eminently possible to ‘authenticate’ oneself using that stolen session cookie. Or maybe it’s just that there is no significant difference in difficulty in stealing something in the session cookie vs in the session per se (as the cookie unlocks the session).