Reimplementing Rails’ CSRF protection in Sinatra

David Mears
4 min readMar 19, 2021

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.

A screenshot of my online version of the game Snatch
A screenshot of my work-in-progress app

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).

--

--