blog.mhartl | Michael Hartl's tech blog

2008-08-15

A security issue with Rails secret session keys

Filed under: Git, Insoshi, Ruby on Rails — mhartl @ 21:53

Like most projects that use Rails 2.1, the Insoshi source code ships with a “secret” string (which lives in environment.rb) needed for the new cookie-based sessions. Recently, an alert observer noted that this raises a security issue in Insoshi sessions: the secret key is currently the same for all Insoshi installations, which opens the sessions up to attack (as noted in this discussion thread). This problem is not unique to Insoshi; it affects essentially any Rails application installed from source.

Part of the reason this problem isn’t more widely known is because projects generated using the rails script automatically receive a unique security string. The way we’ve fixed the secret string problem at Insoshi involves piggybacking on the mechanism Rails already has for generating such strings, by replacing the hard-coded string with a file read:

config/environment.rb

Before:


config.action_controller.session = {
    :session_key => '_instant_social_session',
    :secret      => '63143b62...8522327'
  }

After:

.
.
.
require File.join(File.dirname(__FILE__), 'boot')
require 'rails_generator/secret_key_generator'

Rails::Initializer.run do |config|
  .
  .
  .
  # Your secret key for verifying cookie session data integrity.
  # If you change this key, all old sessions will become invalid!
  # Make sure the secret is at least 30 characters and all random,
  # no regular words or you'll be exposed to dictionary attacks.
  secret_file = File.join(RAILS_ROOT, "secret")
  if File.exist?(secret_file)
    secret = File.read(secret_file)
  else
    secret = Rails::SecretKeyGenerator.new("insoshi").generate_secret
    File.open(secret_file, 'w') { |f| f.write(secret) }
  end
  config.action_controller.session = {
    :session_key => '_instant_social_session',
    :secret      => secret
  }
  .
  .
  .

(N.B. The session key _instant_social_session is a hint about the origins of the name Insoshi.) In place of a hard-coded string, the updated code uses the contents of a secret file, if it exists; otherwise, it makes a new string using the same machinery as the rails script (included with the line require 'rails_generator/secret_key_generator') and writes it to the secret file.

It’s important at this point to prevent our source code management tool from versioning the secret file, since the whole point of this exercise is to prevent the secret key from being distributed with the source code. Using Git, this is trivial; we just add ’secret’ to our .gitignore file. (Note: if you are running an application on multiple servers, you should copy the same secret file to each one to ensure that sessions will work with a load-balancer.) Everyone using the Insoshi source code should pull from our GitHub repository to get the update.

Handling session expiration

Unfortunately, the above steps don’t completely solve our problem. The comments in environment.rb note that “If you change this key, all old sessions will become invalid!” That’s not quite accurate; the old sessions don’t merely become invalid: they actually raise an exception, so users with active sessions will be met with your application’s error page, and a CGI::Session::CookieStore::TamperedWithCookie exception will show up in your application’s log file. (The error page goes away if the user reloads the page in their browser, but there’s no way for them to know that.) Serving up error pages to all those users isn’t very friendly behavior, and we’d like to catch the exception and show the page they’re trying to access instead.

This isn’t as simple as it seems, because the exception gets raised deep inside the Rails internals. We can figure out where by running in development mode, where the stack trace look something like this:

CGI::Session::CookieStore::TamperedWithCookie in HomeController#index 

vendor/rails/actionpack/lib/action_controller/session/cookie_store.rb:144:in `unmarshal'
vendor/rails/actionpack/lib/action_controller/session/cookie_store.rb:101:in `restore'
/usr/local/lib/ruby/1.8/cgi/session.rb:304:in `[]'
vendor/rails/actionpack/lib/action_controller/cgi_process.rb:136:in `session'
vendor/rails/actionpack/lib/action_controller/cgi_process.rb:168:in `stale_session_check!'
vendor/rails/actionpack/lib/action_controller/cgi_process.rb:116:in `session'
.
.
.

To catch the exception, we need to override the default restore method in cookie_store.rb. To do that, we need to load our change before the application loads, and the easiest way to do this is with a plugin, which we can generate with a script:

$ script/generate plugin catch_cookie_exception

Once we edit a couple files, the solution is complete:

vendor/plugins/catch_cookie_exception/init.rb

require 'catch_cookie_exception'

vendor/plugins/catch_cookie_exception/lib/catch_cookie_exception.rb

require 'cgi'
require 'cgi/session'
class CGI::Session::CookieStore
  # Restore session data from the cookie.
  # This method overrides the one in
  # actionpack/lib/action_controller/session/cookie_store.rb
  # in order to handle the case of a "tampered" cookie more gracefully.
  # The issue is that changing the 'secret' in config/environment.rb
  # breaks all sessions in such a way that everyone gets an error page
  # the first time they revisit the site.  Catching the exception here
  # prevents this ugly behavior.
  # This is in a plugin so that it loads after Rails but before environment.rb.
  def restore
    @original = read_cookie
    @data = unmarshal(@original) || {}
  rescue CGI::Session::CookieStore::TamperedWithCookie
    logger = Logger.new("#{RAILS_ROOT}/log/#{RAILS_ENV}.log")
    logger.warn "Caught TamperedWithCookie exception on #{Time.now}"
    @data = {}
  end
end

Note that, since the exception could be the result of someone attacking the site by tampering with their cookies, we log the exception for future reference.

UPDATE: The catch_cookie_exception plugin is now available at GitHub.

Acknowledgments

Thanks again to Trevor Turk for alerting us to this issue.

5 Comments »

  1. Rails::SecretKeyGenerator.generate_secret has been deprecated in Rails 2.2. Instead use ActiveSupport::SecureRandom.hex(64)

    Comment by Tom — 2009-06-12 @ 06:24

  2. Indeed, you are right, and we updated the Insoshi source code with this fix along with our Rails 2.2 upgrade a couple months ago, but I didn’t update this post. Thanks for helping it stay up-to-date.

    Comment by Michael Hartl — 2009-06-12 @ 13:22

  3. Hi Michael,

    We’re going to upgrade our app to rails 2.3.3 and we’re using your plugin. Doing some prep work and I think there might be an issue due to the changes to how Cookies work. Will your plugin still work once i upgrade? have you tried it?

    Comment by Karim Helal — 2009-07-20 @ 23:24

  4. I haven’t tried it yet, and don’t have plans to in the short run (I’m soon going on vacation for a couple weeks, among other things). If you try it out, please let me know how it goes.

    Comment by Michael Hartl — 2009-07-21 @ 11:49

  5. Which WordPress theme do you use?

    Comment by theseefly — 2010-01-1 @ 19:26


RSS feed for comments on this post. TrackBack URI

Leave a comment

Blog at WordPress.com.