From ed708307137c811d14e5fd2cb4ea550add381a82 Mon Sep 17 00:00:00 2001 From: Joshua Peek Date: Mon, 15 Dec 2008 16:33:31 -0600 Subject: Switch to Rack based session stores. --- .../lib/action_controller/session/cookie_store.rb | 356 ++++++++++++--------- 1 file changed, 206 insertions(+), 150 deletions(-) (limited to 'actionpack/lib/action_controller/session/cookie_store.rb') diff --git a/actionpack/lib/action_controller/session/cookie_store.rb b/actionpack/lib/action_controller/session/cookie_store.rb index ea0ea4f841..f13c9290c0 100644 --- a/actionpack/lib/action_controller/session/cookie_store.rb +++ b/actionpack/lib/action_controller/session/cookie_store.rb @@ -1,163 +1,219 @@ -require 'cgi' -require 'cgi/session' - -# 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. -# -# 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. -# -# A message digest 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 pregenerated secret in -# config/environment.rb. Set your own for old apps you're upgrading. -# -# Session options: -# -# * :secret: An application-wide key string or block returning a string -# called per generated digest. The block is called with the CGI::Session -# instance as an argument. It's important that the secret is not vulnerable to -# a dictionary attack. Therefore, you should choose a secret consisting of -# random numbers and letters and more than 30 characters. Examples: -# -# :secret => '449fe2e7daee471bffae2fd8dc02313d' -# :secret => Proc.new { User.current_user.secret_key } -# -# * :digest: The message digest algorithm used to verify session -# integrity defaults to 'SHA1' but may be any digest provided by OpenSSL, -# such as 'MD5', 'RIPEMD160', 'SHA256', etc. -# -# To generate a secret key for an existing application, run -# "rake secret" and set the key in config/environment.rb. -# -# Note that changing digest or secret invalidates all existing sessions! -class CGI::Session::CookieStore - # Cookies can typically store 4096 bytes. - MAX = 4096 - SECRET_MIN_LENGTH = 30 # characters - - # 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 session_key option is required. - if options['session_key'].blank? - raise ArgumentError, 'A session_key is required to write a cookie containing the session data. Use config.action_controller.session = { :session_key => "_myapp_session", :secret => "some secret phrase" } in config/environment.rb' - end +module ActionController + module Session + # 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. + # + # 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. + # + # A message digest 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 pregenerated secret + # in config/environment.rb. Set your own for old apps you're upgrading. + # + # Session options: + # + # * :secret: An application-wide key string or block returning a + # string called per generated digest. The block is called with the + # CGI::Session instance as an argument. It's important that the secret + # is not vulnerable to a dictionary attack. Therefore, you should choose + # a secret consisting of random numbers and letters and more than 30 + # characters. Examples: + # + # :secret => '449fe2e7daee471bffae2fd8dc02313d' + # :secret => Proc.new { User.current_user.secret_key } + # + # * :digest: The message digest algorithm used to verify session + # integrity defaults to 'SHA1' but may be any digest provided by OpenSSL, + # such as 'MD5', 'RIPEMD160', 'SHA256', etc. + # + # To generate a secret key for an existing application, run + # "rake secret" and set the key in config/environment.rb. + # + # Note that changing digest or secret invalidates all existing sessions! + class CookieStore + # Cookies can typically store 4096 bytes. + MAX = 4096 + SECRET_MIN_LENGTH = 30 # characters + + DEFAULT_OPTIONS = { + :domain => nil, + :path => "/", + :expire_after => nil + }.freeze + + ENV_SESSION_KEY = "rack.session".freeze + ENV_SESSION_OPTIONS_KEY = "rack.session.options".freeze + HTTP_SET_COOKIE = "Set-Cookie".freeze + + # Raised when storing more than 4K of session data. + class CookieOverflow < StandardError; end + + def initialize(app, options = {}) + options = options.dup + + @app = app + + # The session_key option is required. + ensure_session_key(options[:key]) + @key = options.delete(:key).freeze + + # The secret option is required. + ensure_secret_secure(options[:secret]) + @secret = options.delete(:secret).freeze + + @digest = options.delete(:digest) || 'SHA1' + @verifier = verifier_for(@secret, @digest) + + @default_options = DEFAULT_OPTIONS.merge(options).freeze + + freeze + end - # The secret option is required. - ensure_secret_secure(options['secret']) - - # Keep the session and its secret on hand so we can read and write cookies. - @session, @secret = session, options['secret'] - - # Message digest defaults to SHA1. - @digest = options['digest'] || 'SHA1' - - # 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'], - 'http_only' => options['session_http_only'] - } - - # 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 + class SessionHash < Hash + def initialize(middleware, env) + @middleware = middleware + @env = env + @loaded = false + end + + def [](key) + load! unless @loaded + super + end + + def []=(key, value) + load! unless @loaded + super + end + + def to_hash + {}.replace(self) + end + + private + def load! + replace(@middleware.send(:load_session, @env)) + @loaded = true + end + end - # To prevent users from using something insecure like "Password" we make sure that the - # secret they've provided is at least 30 characters in length. - def ensure_secret_secure(secret) - # There's no way we can do this check if they've provided a proc for the - # secret. - return true if secret.is_a?(Proc) + def call(env) + session_data = SessionHash.new(self, env) + original_value = session_data.dup - if secret.blank? - raise ArgumentError, %Q{A secret is required to generate an integrity hash for cookie session data. Use config.action_controller.session = { :session_key => "_myapp_session", :secret => "some secret phrase of at least #{SECRET_MIN_LENGTH} characters" } in config/environment.rb} - end + env[ENV_SESSION_KEY] = session_data + env[ENV_SESSION_OPTIONS_KEY] = @default_options.dup - if secret.length < SECRET_MIN_LENGTH - raise ArgumentError, %Q{Secret should be something secure, like "#{CGI::Session.generate_unique_id}". The value you provided, "#{secret}", is shorter than the minimum length of #{SECRET_MIN_LENGTH} characters} - end - end + status, headers, body = @app.call(env) - # Restore session data from the cookie. - def restore - @original = read_cookie - @data = unmarshal(@original) || {} - end + unless env[ENV_SESSION_KEY] == original_value + session_data = marshal(env[ENV_SESSION_KEY].to_hash) - # Wait until close to write the session data cookie. - def update; end + raise CookieOverflow if session_data.size > MAX - # Write the session data cookie if it was loaded and has changed. - def close - if defined?(@data) && !@data.blank? - updated = marshal(@data) - raise CookieOverflow if updated.size > MAX - write_cookie('value' => updated) unless updated == @original - end - end + options = env[ENV_SESSION_OPTIONS_KEY] + cookie = Hash.new + cookie[:value] = session_data + unless options[:expire_after].nil? + cookie[:expires] = Time.now + options[:expire_after] + end - # Delete the session data by setting an expired cookie with no data. - def delete - @data = nil - clear_old_cookie_value - write_cookie('value' => nil, 'expires' => 1.year.ago) - end + cookie = build_cookie(@key, cookie.merge(options)) + case headers[HTTP_SET_COOKIE] + when Array + headers[HTTP_SET_COOKIE] << cookie + when String + headers[HTTP_SET_COOKIE] = [headers[HTTP_SET_COOKIE], cookie] + when nil + headers[HTTP_SET_COOKIE] = cookie + end + end - private - # Marshal a session hash into safe cookie data. Include an integrity hash. - def marshal(session) - verifier.generate(session) - end - - # Unmarshal cookie data to a hash and verify its integrity. - def unmarshal(cookie) - if cookie - verifier.verify(cookie) + [status, headers, body] end - rescue ActiveSupport::MessageVerifier::InvalidSignature - delete - raise TamperedWithCookie - 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 - - # Clear cookie value so subsequent new_session doesn't reload old data. - def clear_old_cookie_value - @session.cgi.cookies[@cookie_options['name']].clear - end - - def verifier - if @secret.respond_to?(:call) - key = @secret.call - else - key = @secret - end - ActiveSupport::MessageVerifier.new(key, @digest) + private + # Should be in Rack::Utils soon + def build_cookie(key, value) + case value + when Hash + domain = "; domain=" + value[:domain] if value[:domain] + path = "; path=" + value[:path] if value[:path] + # According to RFC 2109, we need dashes here. + # N.B.: cgi.rb uses spaces... + expires = "; expires=" + value[:expires].clone.gmtime. + strftime("%a, %d-%b-%Y %H:%M:%S GMT") if value[:expires] + secure = "; secure" if value[:secure] + httponly = "; httponly" if value[:httponly] + value = value[:value] + end + value = [value] unless Array === value + cookie = Rack::Utils.escape(key) + "=" + + value.map { |v| Rack::Utils.escape(v) }.join("&") + + "#{domain}#{path}#{expires}#{secure}#{httponly}" + end + + def load_session(env) + request = Rack::Request.new(env) + session_data = request.cookies[@key] + unmarshal(session_data) || {} + end + + # Marshal a session hash into safe cookie data. Include an integrity hash. + def marshal(session) + @verifier.generate(session) + end + + # Unmarshal cookie data to a hash and verify its integrity. + def unmarshal(cookie) + @verifier.verify(cookie) if cookie + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def ensure_session_key(key) + if key.blank? + raise ArgumentError, 'A session_key is required to write a ' + + 'cookie containing the session data. Use ' + + 'config.action_controller.session = { :session_key => ' + + '"_myapp_session", :secret => "some secret phrase" } in ' + + 'config/environment.rb' + end + end + + # To prevent users from using something insecure like "Password" we make sure that the + # secret they've provided is at least 30 characters in length. + def ensure_secret_secure(secret) + # There's no way we can do this check if they've provided a proc for the + # secret. + return true if secret.is_a?(Proc) + + if secret.blank? + raise ArgumentError, "A secret is required to generate an " + + "integrity hash for cookie session data. Use " + + "config.action_controller.session = { :session_key => " + + "\"_myapp_session\", :secret => \"some secret phrase of at " + + "least #{SECRET_MIN_LENGTH} characters\" } " + + "in config/environment.rb" + end + + if secret.length < SECRET_MIN_LENGTH + raise ArgumentError, "Secret should be something secure, " + + "like \"#{ActiveSupport::SecureRandom.hex(16)}\". The value you " + + "provided, \"#{secret}\", is shorter than the minimum length " + + "of #{SECRET_MIN_LENGTH} characters" + end + end + + def verifier_for(secret, digest) + key = secret.respond_to?(:call) ? secret.call : secret + ActiveSupport::MessageVerifier.new(key, digest) + end end + end end -- cgit v1.2.3