Chris Heald bio photo

Chris Heald

Chief Architect for Mashable. Rubyist, husband, father, and all around tall guy.

No, Rails' CookieStore isn't broken

A post recently hit the Full Disclosure seclist titled "Move away from CookieStore if you care about your users and their security". The post discusses a property of session cookies - notably, that hitting "logout" doesn't prevent a cookie from being reused to regain that session later, since if someone manages to jack one of your users' cookies, they can just replay that cookie again at any time and gain access to the users' account.

It's worth first noting that this vulnerability requires your user's session cookies to be compromised in the first place, so the whole vulnerability hinges on with "if your user is already owned, then..."

That said, yes, if you use sessions naively, then a compromised cookie may be used to gain access to a user's account so long as the application's session secret hasn't changed (thereby invalidating the cookie signature). Note that this is true for all session stores, though in the case of serverside sessions, this only holds until the session gets swept (which may happen on explicit logout, but does not necessarily happen at a defined time otherwise); presuming you have some kind of session TTL in play, an attacker could keep their jacked session ID active indefinitely there, as well. A hijacked session cookie is Bad News (which is why you should be using HTTPS and HTTPS-only cookies!) no matter how you slice it.

Fortunately, if you're worried about this class of attack, mitigating it is Pretty Darn Simple.

If you just want parity with serverside session stores that just perform expired session sweeps, then you can enforce a TTL on a session by just providing a TTL value in the session, and validating that when the session is read, then updating it when the session is written. You could do this trivially with a Rack middleware, or if you just want it in your app:

class ApplicationController
  before_filter :validate_session_timestamp
  after_filter  :persist_session_timestamp

  SESSION_TTL = 48.hours
  def validate_session_timestamp
    if user? && session.key?(:ttl) && session[:ttl] < SESSION_TTL.ago
      reset_session
      current_user = nil
      redirect_to login_path
    end
  end

  def persist_session_timestamp
    session[:ttl] = Time.now if user?
  end
end

That's it. Any session that hasn't been touched in 48 hours won't validate and will get tossed out, same as serverside sessions (and as a bonus, you don't have to do any session sweeping yourself! Hooray!) This does leave the cookie vulerable to TTL refreshes, so perhaps you want something more robust.

Something that neither CookieStore or server-side stores can do by default is maintain a list of sessions associated with a given user, and provide the user a means to revoke access granted to previously-granted sessions. Consider the case where you walk away from a public computer having forgotten to hit "log out" - you have no means of invalidating that session from another computer. This is a problem!

Fortunately, it's trivial enough to just save a list of active sessions if desired:

class User
  # Presume an active_sessions field on the model that is large enough to hold some list of sessions:
  serialize :active_sessions, Array

  def activate_session(id)
    active_sessions.push id unless active_sessions.include? id
    save
  end

  def deactivate_session(id)
    active_sessions.delete id
    save
  end
end

class SessionsController
  def login
    # ...
    current_user.activate_session session[:session_id]
  end

  def logout
    current_user.deactivate_session session[:session_id]
    reset_session
    # ...
  end
end

class ApplicationController
  before_filter :validate_active_session

  def validate_active_session
    if user? && !current_user.active_sessions.include? session[:session_id]
      reset_session
      redirect_to login_path
    end
  end
end

You could get more complex with this and save things like the last IP and geolocation that each session was active from, and present that to the user, GMail-style. You could enforce a maximum number of sessions that can be active at any given time. This makes it easy to let users log out other sessions:

class User
  def expire_sessions!(active)
    self.active_sessions = [active]
    save
  end
end

class UserController
  def logout_other_sessions
    current_user.expire_sessions! session[:session_id]
    # Redirect or whatever
  end
end

This technique is applicable to all session stores, not just CookieStore (you won't be able to get a list of sessions for a given user ID by default in ActiveRecordStore or whatever other serverside store you might want to use). You can give your users proactive control over their account security, and keep using CookieStore with all its benefits (like being invulnerable to session fixation!)