We build Web & Mobile Applications.
When the default session store for Rails was changed to use cookies last year it caused quite a furore: “It’s not secret! It’s not safe!” The truth is it was never meant to be: in most circumstances you shouldn’t really be storing data in cookies that need to be protected so strongly. But I don’t really want to re-open that can of worms! Instead I want to look at a different cookie-related situation that arose during some optimisation work I’ve been doing on an existing Rails application.
The scenario is straightforward enough: the application stores a numeric record ID in a persistent cookie that, while perfectly safe to be seen by users, shouldn’t be changed by them. The solution currently used in the application is equally straightforward: the data is encrypted (using the EzCryto gem and AES encryption) before being written to the cookie, and then decrypted when the data is read back in.
In situations where cookie contents really shouldn’t be seen by users then encryption is the way to go (unless of course you can find a way of not using a cookie at all), however for a simple numeric ID where tampering is the only thing we need to protect against then it seems like overkill. A better solution is to take inspiration from the Rails cookie session store and use a HMAC.
Here’s the code (if it seems familiar it’s because it borrows heavily from the Rails cookie session store):
require 'openssl'
module ActionController
class TamperProofCookieJar < ActionController::CookieJar
class TamperedWithCookie < StandardError;
attr_reader :cookie_name
def initialize(cookie_name)
@cookie_name = cookie_name.to_s
end
end
def initialize(controller, secret, digest = 'SHA1')
super(controller)
@secret = secret
@digest = OpenSSL::Digest::Digest.new(digest)
end
def [](name, protect_from_forgery = false)
if (value = super(name)) && protect_from_forgery
value, hmac = value.to_s.split('--')
unless hmac == generate_digest(value) || hmac == generate_digest(value = CGI.unescape(value))
raise TamperedWithCookie.new(name)
end
end
value
end
def []=(name, options)
if options.is_a?(Hash)
options = options.stringify_keys
options['name'] = name.to_s
options['value'] = options['protect_from_forgery'] == true ? "#{options['value']}--#{generate_digest(options['value'])}" : options['value'].to_s
else
options = { "name" => name.to_s, "value" => options.to_s }
end
set_cookie(options)
end
protected
def generate_digest(value)
OpenSSL::HMAC.hexdigest(@digest, @secret, value.to_s)
end
end
end
The code makes use of the Ruby OpenSSL library to generate a HMAC using the SHA-1 hashing algorithm. A few notes about the code:
The TamperedWithCookie
exception will be raised on reading a cookie value if it is deemed to have been tampered with. The exception provides a cookie_name
attribute that can be used to get the name of the offending cookie.
The TamperProofCookieJar
class is a subclass of the regular Rails CookieJar
and as such is a drop-in replacement for it with one exception: the cookie value is converted to a string before being written to the cookie - this means if you’re not storing a simple string or number then you’ll need to ensure you have marshalled the data correctly before trying to write it to the cookie (again in most situations you shouldn’t be storing any complex objects in a cookie anyway).
Using the TamperProofCookieJar
is easy:
# Write a regular cookie (by default HMAC protection is NOT used - so this is just like a regular Rails cookie)
cookies[:test_unprotected] = { :value => user.id, :expires => Time.zone.now + 30.days }
# Write a HMAC protected cookie
# user.id = 100
cookies[:test_protected] = { :value => user.id, :protect_from_forgery => true, :expires => Time.zone.now + 30.days }
# Read a regular cookie (again no protection is the default)
user_id = cookies[:test_unprotected]
=> "100"
# Read a HMAC protected cookie (the boolean parameter turns on HMAC protection)
# Raises TamperProofCookieJar::TamperedWithCookie if the cookie appears to have been changed
user_id = cookies[:test_protected, true]
=> "100"
# Read a raw HMAC protected cookie
# This returns the actual contents of the cookie, including the HMAC, and does not perform tamper checking
user_id = cookies[:test_protected]
=> "100--2157acbbe09b9f412cee3b97ad2e2c2136d3f5e1"
If you want to replace the Rails CookieJar
with the TamperProofCookieJar
in your application you can override the cookies
method in your ApplicationController
like so:
class ApplicationController < ActionController::Base
def cookies
ActionController::TamperProofCookieJar.new(self, 'this should be a long secret key')
end
end
The second parameter should be a secret key that is hard to guess, just like the one used by the Rails cookie session store.
As I said earlier, the TamperProofCookieJar
was created as part of an optimisation project, so how does its performance compare with the original encryption solution? Using a simple benchmark on 20,000 encryptions versus 20,000 HMACs gives the following results:
This gives a 3.4x performance increase which on a high volume site such as the one I’m working on is a worthy saving!
My original intention was to submit this as a patch to Rails however I don’t think it will be seen as “useful” enough for the wider Rails community to be included in core. Instead I’m leaning towards making it a plugin: so please let me know if you have any problems with the code or have any suggestions for how it can be improved and when time allows I’ll put it all together.