Skip to content
Browse files

Introduce a cookie-based session store as the Rails default. Sessions…

… typically contain at most a user_id and flash message; both fit within the 4K cookie size limit. A secure hash is included with the cookie to ensure data integrity (a user cannot alter his user_id without knowing the secret key included in the hash). If you have more than 4K of session data or don't want your data to be visible to the user, pick another session store. Cookie-based sessions are dramatically faster than the alternatives.

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@6184 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
1 parent 2afc01b commit f29857690fee7fa8350f79922e349669480c0329 @jeremy jeremy committed Feb 21, 2007
View
2 actionpack/CHANGELOG
@@ -1,5 +1,7 @@
*SVN*
+* Introduce a cookie-based session store as the Rails default. Sessions typically contain at most a user_id and flash message; both fit within the 4K cookie size limit. A secure hash is included with the cookie to ensure data integrity (a user cannot alter his user_id without knowing the secret key included in the hash). If you have more than 4K of session data or don't want your data to be visible to the user, pick another session store. Cookie-based sessions are dramatically faster than the alternatives. [Jeremy Kemper]
+
* Added .erb and .builder as preferred aliases to the now deprecated .rhtml and .rxml extensions [Chad Fowler]. This is done to separate the renderer from the mime type. .erb templates are often used to render emails, atom, csv, whatever. So labeling them .rhtml doesn't make too much sense. The same goes for .rxml, which can be used to build everything from HTML to Atom to whatever. .rhtml and .rxml will continue to work until Rails 3.0, though. So this is a slow phasing out. All generators and examples will start using the new aliases, though.
* Added caching option to AssetTagHelper#stylesheet_link_tag and AssetTagHelper#javascript_include_tag [DHH]. Examples:
View
7 actionpack/lib/action_controller/cgi_process.rb
@@ -2,6 +2,7 @@
require 'action_controller/cgi_ext/cookie_performance_fix'
require 'action_controller/cgi_ext/raw_post_data_fix'
require 'action_controller/cgi_ext/session_performance_fix'
+require 'action_controller/session/cookie_store'
module ActionController #:nodoc:
class Base
@@ -36,9 +37,9 @@ class CgiRequest < AbstractRequest #:nodoc:
attr_accessor :cgi, :session_options
DEFAULT_SESSION_OPTIONS = {
- :database_manager => CGI::Session::PStore,
- :prefix => "ruby_sess.",
- :session_path => "/"
+ :database_manager => CGI::Session::CookieStore, # store data in cookie
+ :prefix => "ruby_sess.", # prefix session file names
+ :session_path => "/" # available to all paths in app
} unless const_defined?(:DEFAULT_SESSION_OPTIONS)
def initialize(cgi, session_options = {})
View
113 actionpack/lib/action_controller/session/cookie_store.rb
@@ -0,0 +1,113 @@
+require 'cgi'
+require 'cgi/session'
+require 'base64' # to make marshaled data HTTP header friendly
+require 'digest/sha2' # to generate the data integrity hash
+
+# This cookie-based session store is the Rails default. Sessions typically
+# contain at most a user_id and flash message; both fit within the 4K cookie
+# size limit. Cookie-based sessions are dramatically faster than the
+# alternatives.
+#
+# A secure hash is included with the cookie to ensure data integrity:
+# a user cannot alter his user_id without knowing the secret key included in
+# the hash. New apps are generated with a session :secret option in
+# Application Controller. Set your own for old apps you're upgrading.
+# Note that changing the secret invalidates all existing sessions!
+#
+# If you have more than 4K of session data or don't want your data to be
+# visible to the user, pick another session store.
+#
+# CookieOverflow is raised if you attempt to store more than 4K of data.
+# TamperedWithCookie is raised if the data integrity check fails.
+class CGI::Session::CookieStore
+ # Cookies can typically store 4096 bytes.
+ MAX = 4096
+
+ # Raised when storing more than 4K of session data.
+ class CookieOverflow < StandardError; end
+
+ # Raised when the cookie fails its integrity check.
+ class TamperedWithCookie < StandardError; end
+
+ # Called from CGI::Session only.
+ def initialize(session, options = {})
+ # The secret option is required.
+ if options['secret'].blank?
+ raise ArgumentError, 'A secret is required to generate an integrity hash for cookie session data. Use session :secret => "some secret phrase" in ApplicationController.'
+ end
+
+ # Keep the session and its secret on hand so we can read and write cookies.
+ @session, @secret = session, options['secret']
+
+ # Default cookie options derived from session settings.
+ @cookie_options = {
+ 'name' => options['session_key'],
+ 'path' => options['session_path'],
+ 'domain' => options['session_domain'],
+ 'expires' => options['session_expires'],
+ 'secure' => options['session_secure']
+ }
+
+ # Set no_hidden and no_cookies since the session id is unused and we
+ # set our own data cookie.
+ options['no_hidden'] = true
+ options['no_cookies'] = true
+ end
+
+ # Restore session data from the cookie.
+ def restore
+ @original = read_cookie
+ @data = unmarshal(@original) || {}
+ end
+
+ # Wait until close to write the session data cookie.
+ def update; end
+
+ # Write the session data cookie if it was loaded and has changed.
+ def close
+ if defined? @data
+ updated = marshal(@data)
+ raise CookieOverflow if updated.size > MAX
+ write_cookie('value' => updated) unless updated == @original
+ end
+ end
+
+ # Delete the session data by setting an expired cookie with no data.
+ def delete
+ write_cookie('value' => '', 'expires' => 1.year.ago)
+ end
+
+ private
+ # Marshal a session hash into safe cookie data. Include an integrity hash.
+ def marshal(session)
+ data = Base64.encode64(Marshal.dump(session)).chop
+ "#{data}--#{generate_digest(data)}"
+ end
+
+ # Unmarshal cookie data to a hash and verify its integrity.
+ def unmarshal(cookie)
+ if cookie
+ data, digest = cookie.split('--')
+ raise TamperedWithCookie unless digest == generate_digest(data)
+ Marshal.load(Base64.decode64(data))
+ end
+ end
+
+ # Generate the inline SHA512 message digest. Larger (128 bytes) than SHA256
+ # (64 bytes) or RMD160 (40 bytes), but small relative to the 4096 byte
+ # max cookie size.
+ def generate_digest(data)
+ Digest::SHA512.hexdigest "#{data}#{@secret}"
+ end
+
+ # Read the session data cookie.
+ def read_cookie
+ @session.cgi.cookies[@cookie_options['name']].first
+ end
+
+ # CGI likes to make you hack.
+ def write_cookie(options)
+ cookie = CGI::Cookie.new(@cookie_options.merge(options))
+ @session.cgi.send :instance_variable_set, '@output_cookies', [cookie]
+ end
+end
View
1 actionpack/lib/action_controller/session_management.rb
@@ -1,3 +1,4 @@
+require 'action_controller/session/cookie_store'
require 'action_controller/session/drb_store'
require 'action_controller/session/mem_cache_store'
if Object.const_defined?(:ActiveRecord)
View
137 actionpack/test/controller/session/cookie_store_test.rb
@@ -0,0 +1,137 @@
+require "#{File.dirname(__FILE__)}/../../abstract_unit"
+require 'action_controller/cgi_process'
+require 'action_controller/cgi_ext/cgi_ext'
+
+require 'stringio'
+
+# Expose for tests.
+class CGI
+ attr_reader :output_cookies, :output_hidden
+
+ class Session
+ attr_reader :dbman
+
+ class CookieStore
+ attr_reader :data, :original, :cookie_options
+ end
+ end
+end
+
+class CookieStoreTest < Test::Unit::TestCase
+ DefaultSessionOptions = {
+ 'database_manager' => CGI::Session::CookieStore,
+ 'session_key' => '_myapp_session',
+ 'secret' => 'Keep it secret; keep it safe.',
+ 'no_cookies' => true,
+ 'no_hidden' => true
+ }
+
+ module Cookies
+ EMPTY = ['BAh7AA==--fda6e506d1cc14a1d8e97fd3f5abf77e756ff2d987b069e5f9b0fbadb62ca6fb3cf523e8dfc61464dd98d7bd2d675e0713ce54226f428e521b4c5d21d2389eae', {}]
+ A_ONE = ['BAh7BiIGYWkG--8dfd099b297a60f6742933b1217b81e91c50237eedd8b25f3ce47b86394e14de3b17128225ba984e7d8660f7777e33979b8d98091dc87400be8c54ebbfdbe599', { 'a' => 1 }]
+ TYPICAL = ['BAh7ByIMdXNlcl9pZGkBeyIKZmxhc2h7BiILbm90aWNlIgxIZXkgbm93--251fa4706464e87bcb90c76a27a1dee2410ff81a1ba9903f9760263ad44e739a42d0a5d5d7229087ddb4b3e1d6b956a6c4f6a2f8dcb5a5b281a342fed12d38c0', { 'user_id' => 123, 'flash' => { 'notice' => 'Hey now' }}]
+ FLASHED = ['BAh7ByIMdXNlcl9pZGkBeyIKZmxhc2h7AA==--a574ffd23d744c363f94a75b449d02dd619fd9409978ea0a2797c98dc638bff9fe0f9cacb2106b1610f0731b386416bcca6e11e031b7885719ba8c956dfd6f2c', { 'user_id' => 123, 'flash' => {} }]
+ end
+
+ def setup
+ ENV.delete('HTTP_COOKIE')
+ end
+
+ def test_raises_argument_error_if_missing_secret
+ [nil, ''].each do |blank|
+ assert_raise(ArgumentError, blank.inspect) { new_session 'secret' => blank }
+ end
+ end
+
+ def test_reconfigures_session_to_omit_id_cookie_and_hidden_field
+ new_session do |session|
+ assert_equal true, @options['no_hidden']
+ assert_equal true, @options['no_cookies']
+ end
+ end
+
+ def test_restore_unmarshals_missing_cookie_as_empty_hash
+ new_session do |session|
+ assert_nil session.dbman.data
+ assert_nil session['test']
+ assert_equal Hash.new, session.dbman.data
+ end
+ end
+
+ def test_restore_unmarshals_good_cookies
+ [Cookies::EMPTY, Cookies::A_ONE, Cookies::TYPICAL].each do |value, expected|
+ set_cookie! value
+ new_session do |session|
+ assert_nil session['lazy loads the data hash']
+ assert_equal expected, session.dbman.data
+ end
+ end
+ end
+
+ def test_close_doesnt_write_cookie_if_data_is_unchanged
+ set_cookie! Cookies::TYPICAL.first
+ new_session do |session|
+ assert_nil session.cgi.output_cookies, session.cgi.output_cookies.inspect
+ session['user_id'] = session['user_id']
+ assert_nil session.cgi.output_cookies, session.cgi.output_cookies.inspect
+ end
+ end
+
+ def test_close_marshals_and_writes_cookie
+ set_cookie! Cookies::TYPICAL.first
+ new_session do |session|
+ assert_nil session.cgi.output_cookies, session.cgi.output_cookies.inspect
+ session['flash'] = {}
+ assert_nil session.cgi.output_cookies, session.cgi.output_cookies.inspect
+ session.close
+ assert_equal 1, session.cgi.output_cookies.size
+ cookie = session.cgi.output_cookies.first
+ assert_equal ['_myapp_session', [Cookies::FLASHED.first]],
+ [cookie.name, cookie.value]
+ end
+ end
+
+ def test_delete_writes_expired_empty_cookie
+ set_cookie! Cookies::TYPICAL.first
+ new_session do |session|
+ assert_nil session.cgi.output_cookies, session.cgi.output_cookies.inspect
+ session.delete
+ assert_equal 1, session.cgi.output_cookies.size
+ cookie = session.cgi.output_cookies.first
+ assert_equal ['_myapp_session', [], 1.year.ago.to_date],
+ [cookie.name, cookie.value, cookie.expires.to_date]
+ end
+ end
+
+ private
+ def set_cookie!(value)
+ ENV['HTTP_COOKIE'] = "_myapp_session=#{value}"
+ end
+
+ def new_session(options = {})
+ with_cgi do |cgi|
+ assert_nil cgi.output_hidden, "Output hidden params should be empty: #{cgi.output_hidden.inspect}"
+ assert_nil cgi.output_cookies, "Output cookies should be empty: #{cgi.output_cookies.inspect}"
+
+ @options = DefaultSessionOptions.merge(options)
+ session = CGI::Session.new(cgi, @options)
+
+ assert_nil cgi.output_hidden, "Output hidden params should be empty: #{cgi.output_hidden.inspect}"
+ assert_nil cgi.output_cookies, "Output cookies should be empty: #{cgi.output_cookies.inspect}"
+
+ yield session if block_given?
+ session
+ end
+ end
+
+ def with_cgi
+ ENV['REQUEST_METHOD'] = 'GET'
+ ENV['HTTP_HOST'] = 'example.com'
+ ENV['QUERY_STRING'] = ''
+
+ $stdin, old_stdin = StringIO.new(''), $stdin
+ yield CGI.new
+ ensure
+ $stdin = old_stdin
+ end
+end

0 comments on commit f298576

Please sign in to comment.
Something went wrong with that request. Please try again.