diff options
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | lib/rack.rb | 6 | ||||
-rw-r--r-- | lib/rack/constants.rb | 1 | ||||
-rw-r--r-- | lib/rack/encryptor.rb | 184 | ||||
-rw-r--r-- | lib/rack/session/abstract/id.rb | 531 | ||||
-rw-r--r-- | lib/rack/session/cookie.rb | 303 | ||||
-rw-r--r-- | lib/rack/session/memcache.rb | 10 | ||||
-rw-r--r-- | lib/rack/session/pool.rb | 78 | ||||
-rw-r--r-- | rack.gemspec | 2 | ||||
-rw-r--r-- | test/spec_encryptor.rb | 166 | ||||
-rw-r--r-- | test/spec_session_abstract_id.rb | 85 | ||||
-rw-r--r-- | test/spec_session_abstract_persisted.rb | 70 | ||||
-rw-r--r-- | test/spec_session_abstract_persisted_secure_secure_session_hash.rb | 86 | ||||
-rw-r--r-- | test/spec_session_abstract_session_hash.rb | 104 | ||||
-rw-r--r-- | test/spec_session_cookie.rb | 591 | ||||
-rw-r--r-- | test/spec_session_pool.rb | 288 |
16 files changed, 3 insertions, 2503 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c447f182..2f379888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file. For info on - `Rack::Request#POST` now caches an empty hash if input content type is not parseable. ([#749](https://github.com/rack/rack/pull/749), [@jeremyevans](https://github.com/jeremyevans)) - BREAKING CHANGE: Updated `trusted_proxy?` to match full 127.0.0.0/8 network. ([#1781](https://github.com/rack/rack/pull/1781), [@snbloch](https://github.com/snbloch)) - Explicitly deprecate `Rack::File` which was an alias for `Rack::Files`. ([#1811](https://github.com/rack/rack/pull/1720), [@ioquatix](https://github.com/ioquatix)). +- Moved `Rack::Session` into [separate gem](https://github.com/rack/rack-session). ([#1805](https://github.com/rack/rack/pull/1805), [@ioquatix](https://github.com/ioquatix)) ### Fixed diff --git a/lib/rack.rb b/lib/rack.rb index 6ec8bad7..a05c93a7 100644 --- a/lib/rack.rb +++ b/lib/rack.rb @@ -72,10 +72,4 @@ module Rack autoload :Request, "rack/auth/digest/request" end end - - module Session - autoload :Cookie, "rack/session/cookie" - autoload :Pool, "rack/session/pool" - autoload :Memcache, "rack/session/memcache" - end end diff --git a/lib/rack/constants.rb b/lib/rack/constants.rb index 55cfd9fe..6d1becb7 100644 --- a/lib/rack/constants.rb +++ b/lib/rack/constants.rb @@ -57,5 +57,4 @@ module Rack RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' RACK_REQUEST_QUERY_STRING = 'rack.request.query_string' RACK_METHODOVERRIDE_ORIGINAL_METHOD = 'rack.methodoverride.original_method' - RACK_SESSION_UNPACKED_COOKIE_DATA = 'rack.session.unpacked_cookie_data' end diff --git a/lib/rack/encryptor.rb b/lib/rack/encryptor.rb deleted file mode 100644 index 86ad4e05..00000000 --- a/lib/rack/encryptor.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true - -require 'base64' -require 'openssl' -require 'securerandom' -require 'zlib' - -module Rack - class Encryptor - class Error < StandardError - end - - class InvalidSignature < Error - end - - class InvalidMessage < Error - end - - # The secret String must be at least 64 bytes in size. The first 32 bytes - # will be used for the encryption cipher key. The remainder will be used - # for an HMAC key. - # - # Options may include: - # * :serialize_json - # Use JSON for message serialization instead of Marshal. This can be - # viewed as a security ehancement. - # * :pad_size - # Pad encrypted message data, to a multiple of this many bytes - # (default: 32). This can be between 2-4096 bytes, or +nil+ to disable - # padding. - # * :purpose - # Limit messages to a specific purpose. This can be viewed as a - # security enhancement to prevent message reuse from different contexts - # if keys are reused. - # - # Cryptography and Output Format: - # - # urlsafe_encode64(version + random_data + IV + encrypted data + HMAC) - # - # Where: - # * version - 1 byte and is currently always 0x01 - # * random_data - 32 bytes used for generating the per-message secret - # * IV - 16 bytes random initialization vector - # * HMAC - 32 bytes HMAC-SHA-256 of all preceding data, plus the purpose - # value - def initialize(secret, opts = {}) - raise ArgumentError, "secret must be a String" unless String === secret - raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64 - - case opts[:pad_size] - when nil - # padding is disabled - when Integer - raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size] - else - raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil" - end - - @options = { - serialize_json: false, pad_size: 32, purpose: nil - }.update(opts) - - @hmac_secret = secret.dup.force_encoding('BINARY') - @cipher_secret = @hmac_secret.slice!(0, 32) - - @hmac_secret.freeze - @cipher_secret.freeze - end - - def decrypt(base64_data) - data = Base64.urlsafe_decode64(base64_data) - - signature = data.slice!(-32..-1) - - verify_authenticity! data, signature - - # The version is reserved for future - _version = data.slice!(0, 1) - message_secret = data.slice!(0, 32) - cipher_iv = data.slice!(0, 16) - - cipher = new_cipher - cipher.decrypt - - set_cipher_key(cipher, cipher_secret_from_message_secret(message_secret)) - - cipher.iv = cipher_iv - data = cipher.update(data) << cipher.final - - deserialized_message data - rescue ArgumentError - raise InvalidSignature, 'Message invalid' - end - - def encrypt(message) - version = "\1" - - serialized_payload = serialize_payload(message) - message_secret, cipher_secret = new_message_and_cipher_secret - - cipher = new_cipher - cipher.encrypt - - set_cipher_key(cipher, cipher_secret) - - cipher_iv = cipher.random_iv - - encrypted_data = cipher.update(serialized_payload) << cipher.final - - data = String.new - data << version - data << message_secret - data << cipher_iv - data << encrypted_data - data << compute_signature(data) - - Base64.urlsafe_encode64(data) - end - - private - - def new_cipher - OpenSSL::Cipher.new('aes-256-ctr') - end - - def new_message_and_cipher_secret - message_secret = SecureRandom.random_bytes(32) - - [message_secret, cipher_secret_from_message_secret(message_secret)] - end - - def cipher_secret_from_message_secret(message_secret) - OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @cipher_secret, message_secret) - end - - def set_cipher_key(cipher, key) - cipher.key = key - end - - def serializer - @serializer ||= @options[:serialize_json] ? JSON : Marshal - end - - def compute_signature(data) - signing_data = data - signing_data += @options[:purpose] if @options[:purpose] - - OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @hmac_secret, signing_data) - end - - def verify_authenticity!(data, signature) - raise InvalidMessage, 'Message is invalid' if data.nil? || signature.nil? - - unless Rack::Utils.secure_compare(signature, compute_signature(data)) - raise InvalidSignature, 'HMAC is invalid' - end - end - - # Returns a serialized payload of the message. If a :pad_size is supplied, - # the message will be padded. The first 2 bytes of the returned string will - # indicating the amount of padding. - def serialize_payload(message) - serialized_data = serializer.dump(message) - - return "#{[0].pack('v')}#{serialized_data}" if @options[:pad_size].nil? - - padding_bytes = @options[:pad_size] - (2 + serialized_data.size) % @options[:pad_size] - padding_data = SecureRandom.random_bytes(padding_bytes) - - "#{[padding_bytes].pack('v')}#{padding_data}#{serialized_data}" - end - - # Return the deserialized message. The first 2 bytes will be read as the - # amount of padding. - def deserialized_message(data) - # Read the first 2 bytes as the padding_bytes size - padding_bytes, = data.unpack('v') - - # Slice out the serialized_data and deserialize it - serialized_data = data.slice(2 + padding_bytes, data.bytesize) - serializer.load serialized_data - end - end -end diff --git a/lib/rack/session/abstract/id.rb b/lib/rack/session/abstract/id.rb deleted file mode 100644 index 65b93d69..00000000 --- a/lib/rack/session/abstract/id.rb +++ /dev/null @@ -1,531 +0,0 @@ -# frozen_string_literal: true - -# AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net -# bugrep: Andreas Zehnder - -require 'time' -require 'securerandom' -require 'digest/sha2' - -require_relative '../../constants' -require_relative '../../request' -require_relative '../../response' - -module Rack - - module Session - - class SessionId - ID_VERSION = 2 - - attr_reader :public_id - - def initialize(public_id) - @public_id = public_id - end - - def private_id - "#{ID_VERSION}::#{hash_sid(public_id)}" - end - - alias :cookie_value :public_id - alias :to_s :public_id - - def empty?; false; end - def inspect; public_id.inspect; end - - private - - def hash_sid(sid) - Digest::SHA256.hexdigest(sid) - end - end - - module Abstract - # SessionHash is responsible to lazily load the session from store. - - class SessionHash - include Enumerable - attr_writer :id - - Unspecified = Object.new - - def self.find(req) - req.get_header RACK_SESSION - end - - def self.set(req, session) - req.set_header RACK_SESSION, session - end - - def self.set_options(req, options) - req.set_header RACK_SESSION_OPTIONS, options.dup - end - - def initialize(store, req) - @store = store - @req = req - @loaded = false - end - - def id - return @id if @loaded or instance_variable_defined?(:@id) - @id = @store.send(:extract_session_id, @req) - end - - def options - @req.session_options - end - - def each(&block) - load_for_read! - @data.each(&block) - end - - def [](key) - load_for_read! - @data[key.to_s] - end - - def dig(key, *keys) - load_for_read! - @data.dig(key.to_s, *keys) - end - - def fetch(key, default = Unspecified, &block) - load_for_read! - if default == Unspecified - @data.fetch(key.to_s, &block) - else - @data.fetch(key.to_s, default, &block) - end - end - - def has_key?(key) - load_for_read! - @data.has_key?(key.to_s) - end - alias :key? :has_key? - alias :include? :has_key? - - def []=(key, value) - load_for_write! - @data[key.to_s] = value - end - alias :store :[]= - - def clear - load_for_write! - @data.clear - end - - def destroy - clear - @id = @store.send(:delete_session, @req, id, options) - end - - def to_hash - load_for_read! - @data.dup - end - - def update(hash) - load_for_write! - @data.update(stringify_keys(hash)) - end - alias :merge! :update - - def replace(hash) - load_for_write! - @data.replace(stringify_keys(hash)) - end - - def delete(key) - load_for_write! - @data.delete(key.to_s) - end - - def inspect - if loaded? - @data.inspect - else - "#<#{self.class}:0x#{self.object_id.to_s(16)} not yet loaded>" - end - end - - def exists? - return @exists if instance_variable_defined?(:@exists) - @data = {} - @exists = @store.send(:session_exists?, @req) - end - - def loaded? - @loaded - end - - def empty? - load_for_read! - @data.empty? - end - - def keys - load_for_read! - @data.keys - end - - def values - load_for_read! - @data.values - end - - private - - def load_for_read! - load! if !loaded? && exists? - end - - def load_for_write! - load! unless loaded? - end - - def load! - @id, session = @store.send(:load_session, @req) - @data = stringify_keys(session) - @loaded = true - end - - def stringify_keys(other) - # Use transform_keys after dropping Ruby 2.4 support - hash = {} - other.to_hash.each do |key, value| - hash[key.to_s] = value - end - hash - end - end - - # ID sets up a basic framework for implementing an id based sessioning - # service. Cookies sent to the client for maintaining sessions will only - # contain an id reference. Only #find_session, #write_session and - # #delete_session are required to be overwritten. - # - # All parameters are optional. - # * :key determines the name of the cookie, by default it is - # 'rack.session' - # * :path, :domain, :expire_after, :secure, :httponly, and :same_site set - # the related cookie options as by Rack::Response#set_cookie - # * :skip will not a set a cookie in the response nor update the session state - # * :defer will not set a cookie in the response but still update the session - # state if it is used with a backend - # * :renew (implementation dependent) will prompt the generation of a new - # session id, and migration of data to be referenced at the new id. If - # :defer is set, it will be overridden and the cookie will be set. - # * :sidbits sets the number of bits in length that a generated session - # id will be. - # - # These options can be set on a per request basis, at the location of - # <tt>env['rack.session.options']</tt>. Additionally the id of the - # session can be found within the options hash at the key :id. It is - # highly not recommended to change its value. - # - # Is Rack::Utils::Context compatible. - # - # Not included by default; you must require 'rack/session/abstract/id' - # to use. - - class Persisted - DEFAULT_OPTIONS = { - key: RACK_SESSION, - path: '/', - domain: nil, - expire_after: nil, - secure: false, - httponly: true, - defer: false, - renew: false, - sidbits: 128, - cookie_only: true, - secure_random: ::SecureRandom - }.freeze - - attr_reader :key, :default_options, :sid_secure - - def initialize(app, options = {}) - @app = app - @default_options = self.class::DEFAULT_OPTIONS.merge(options) - @key = @default_options.delete(:key) - @cookie_only = @default_options.delete(:cookie_only) - @same_site = @default_options.delete(:same_site) - initialize_sid - end - - def call(env) - context(env) - end - - def context(env, app = @app) - req = make_request env - prepare_session(req) - status, headers, body = app.call(req.env) - res = Rack::Response::Raw.new status, headers - commit_session(req, res) - [status, headers, body] - end - - private - - def make_request(env) - Rack::Request.new env - end - - def initialize_sid - @sidbits = @default_options[:sidbits] - @sid_secure = @default_options[:secure_random] - @sid_length = @sidbits / 4 - end - - # Generate a new session id using Ruby #rand. The size of the - # session id is controlled by the :sidbits option. - # Monkey patch this to use custom methods for session id generation. - - def generate_sid(secure = @sid_secure) - if secure - secure.hex(@sid_length) - else - "%0#{@sid_length}x" % Kernel.rand(2**@sidbits - 1) - end - rescue NotImplementedError - generate_sid(false) - end - - # Sets the lazy session at 'rack.session' and places options and session - # metadata into 'rack.session.options'. - - def prepare_session(req) - session_was = req.get_header RACK_SESSION - session = session_class.new(self, req) - req.set_header RACK_SESSION, session - req.set_header RACK_SESSION_OPTIONS, @default_options.dup - session.merge! session_was if session_was - end - - # Extracts the session id from provided cookies and passes it and the - # environment to #find_session. - - def load_session(req) - sid = current_session_id(req) - sid, session = find_session(req, sid) - [sid, session || {}] - end - - # Extract session id from request object. - - def extract_session_id(request) - sid = request.cookies[@key] - sid ||= request.params[@key] unless @cookie_only - sid - end - - # Returns the current session id from the SessionHash. - - def current_session_id(req) - req.get_header(RACK_SESSION).id - end - - # Check if the session exists or not. - - def session_exists?(req) - value = current_session_id(req) - value && !value.empty? - end - - # Session should be committed if it was loaded, any of specific options like :renew, :drop - # or :expire_after was given and the security permissions match. Skips if skip is given. - - def commit_session?(req, session, options) - if options[:skip] - false - else - has_session = loaded_session?(session) || forced_session_update?(session, options) - has_session && security_matches?(req, options) - end - end - - def loaded_session?(session) - !session.is_a?(session_class) || session.loaded? - end - - def forced_session_update?(session, options) - force_options?(options) && session && !session.empty? - end - - def force_options?(options) - options.values_at(:max_age, :renew, :drop, :defer, :expire_after).any? - end - - def security_matches?(request, options) - return true unless options[:secure] - request.ssl? - end - - # Acquires the session from the environment and the session id from - # the session options and passes them to #write_session. If successful - # and the :defer option is not true, a cookie will be added to the - # response with the session's id. - - def commit_session(req, res) - session = req.get_header RACK_SESSION - options = session.options - - if options[:drop] || options[:renew] - session_id = delete_session(req, session.id || generate_sid, options) - return unless session_id - end - - return unless commit_session?(req, session, options) - - session.send(:load!) unless loaded_session?(session) - session_id ||= session.id - session_data = session.to_hash.delete_if { |k, v| v.nil? } - - if not data = write_session(req, session_id, session_data, options) - req.get_header(RACK_ERRORS).puts("Warning! #{self.class.name} failed to save session. Content dropped.") - elsif options[:defer] and not options[:renew] - req.get_header(RACK_ERRORS).puts("Deferring cookie for #{session_id}") if $VERBOSE - else - cookie = Hash.new - cookie[:value] = cookie_value(data) - cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after] - cookie[:expires] = Time.now + options[:max_age] if options[:max_age] - - if @same_site.respond_to? :call - cookie[:same_site] = @same_site.call(req, res) - else - cookie[:same_site] = @same_site - end - set_cookie(req, res, cookie.merge!(options)) - end - end - public :commit_session - - def cookie_value(data) - data - end - - # Sets the cookie back to the client with session id. We skip the cookie - # setting if the value didn't change (sid is the same) or expires was given. - - def set_cookie(request, res, cookie) - if request.cookies[@key] != cookie[:value] || cookie[:expires] - res.set_cookie_header = - Utils.add_cookie_to_header(res.set_cookie_header, @key, cookie) - end - end - - # Allow subclasses to prepare_session for different Session classes - - def session_class - SessionHash - end - - # All thread safety and session retrieval procedures should occur here. - # Should return [session_id, session]. - # If nil is provided as the session id, generation of a new valid id - # should occur within. - - def find_session(env, sid) - raise '#find_session not implemented.' - end - - # All thread safety and session storage procedures should occur here. - # Must return the session id if the session was saved successfully, or - # false if the session could not be saved. - - def write_session(req, sid, session, options) - raise '#write_session not implemented.' - end - - # All thread safety and session destroy procedures should occur here. - # Should return a new session id or nil if options[:drop] - - def delete_session(req, sid, options) - raise '#delete_session not implemented' - end - end - - class PersistedSecure < Persisted - class SecureSessionHash < SessionHash - def [](key) - if key == "session_id" - load_for_read! - case id - when SessionId - id.public_id - else - id - end - else - super - end - end - end - - def generate_sid(*) - public_id = super - - SessionId.new(public_id) - end - - def extract_session_id(*) - public_id = super - public_id && SessionId.new(public_id) - end - - private - - def session_class - SecureSessionHash - end - - def cookie_value(data) - data.cookie_value - end - end - - class ID < Persisted - def self.inherited(klass) - k = klass.ancestors.find { |kl| kl.respond_to?(:superclass) && kl.superclass == ID } - unless k.instance_variable_defined?(:"@_rack_warned") - warn "#{klass} is inheriting from #{ID}. Inheriting from #{ID} is deprecated, please inherit from #{Persisted} instead" if $VERBOSE - k.instance_variable_set(:"@_rack_warned", true) - end - super - end - - # All thread safety and session retrieval procedures should occur here. - # Should return [session_id, session]. - # If nil is provided as the session id, generation of a new valid id - # should occur within. - - def find_session(req, sid) - get_session req.env, sid - end - - # All thread safety and session storage procedures should occur here. - # Must return the session id if the session was saved successfully, or - # false if the session could not be saved. - - def write_session(req, sid, session, options) - set_session req.env, sid, session, options - end - - # All thread safety and session destroy procedures should occur here. - # Should return a new session id or nil if options[:drop] - - def delete_session(req, sid, options) - destroy_session req.env, sid, options - end - end - end - end -end diff --git a/lib/rack/session/cookie.rb b/lib/rack/session/cookie.rb deleted file mode 100644 index e4b91f1f..00000000 --- a/lib/rack/session/cookie.rb +++ /dev/null @@ -1,303 +0,0 @@ -# frozen_string_literal: true - -require 'openssl' -require 'zlib' -require 'json' -require 'base64' -require 'delegate' - -require_relative '../constants' -require_relative '../utils' -require_relative 'abstract/id' -require_relative '../encryptor' - -module Rack - - module Session - - # Rack::Session::Cookie provides simple cookie based session management. - # By default, the session is a Ruby Hash that is serialized and encoded as - # a cookie set to :key (default: rack.session). - # - # This middleware accepts a :secrets option which enables encryption of - # session cookies. This option should be one or more random "secret keys" - # that are each at least 64 bytes in length. Multiple secret keys can be - # supplied in an Array, which is useful when rotating secrets. - # - # Several options are also accepted that are passed to Rack::Encryptor. - # These options include: - # * :serialize_json - # Use JSON for message serialization instead of Marshal. This can be - # viewed as a security ehancement. - # * :gzip_over - # For message data over this many bytes, compress it with the deflate - # algorithm. - # - # Refer to Rack::Encryptor for more details on these options. - # - # Prior to version TODO, the session hash was stored as base64 encoded - # marshalled data. When a :secret option was supplied, the integrity of the - # encoded data was protected with HMAC-SHA1. This functionality is still - # supported using a set of a legacy options. - # - # Lastly, a :coder option is also accepted. When used, both encryption and - # the legacy HMAC will be skipped. This option could create security issues - # in your application! - # - # Example: - # - # use Rack::Session::Cookie, { - # key: 'rack.session', - # domain: 'foo.com', - # path: '/', - # expire_after: 2592000, - # secrets: 'a randomly generated, raw binary string 64 bytes in size', - # } - # - # Example using legacy HMAC options: - # - # Rack::Session:Cookie.new(application, { - # # The secret used for legacy HMAC cookies, this enables the functionality - # legacy_hmac_secret: 'legacy secret', - # # legacy_hmac_coder will default to Rack::Session::Cookie::Base64::Marshal - # legacy_hmac_coder: Rack::Session::Cookie::Identity.new, - # # legacy_hmac will default to OpenSSL::Digest::SHA1 - # legacy_hmac: OpenSSL::Digest::SHA256 - # }) - # - # Example of a cookie with no encoding: - # - # Rack::Session::Cookie.new(application, { - # :coder => Rack::Session::Cookie::Identity.new - # }) - # - # Example of a cookie with custom encoding: - # - # Rack::Session::Cookie.new(application, { - # :coder => Class.new { - # def encode(str); str.reverse; end - # def decode(str); str.reverse; end - # }.new - # }) - # - - class Cookie < Abstract::PersistedSecure - # Encode session cookies as Base64 - class Base64 - def encode(str) - ::Base64.strict_encode64(str) - end - - def decode(str) - ::Base64.decode64(str) - end - - # Encode session cookies as Marshaled Base64 data - class Marshal < Base64 - def encode(str) - super(::Marshal.dump(str)) - end - - def decode(str) - return unless str - ::Marshal.load(super(str)) rescue nil - end - end - - # N.B. Unlike other encoding methods, the contained objects must be a - # valid JSON composite type, either a Hash or an Array. - class JSON < Base64 - def encode(obj) - super(::JSON.dump(obj)) - end - - def decode(str) - return unless str - ::JSON.parse(super(str)) rescue nil - end - end - - class ZipJSON < Base64 - def encode(obj) - super(Zlib::Deflate.deflate(::JSON.dump(obj))) - end - - def decode(str) - return unless str - ::JSON.parse(Zlib::Inflate.inflate(super(str))) - rescue - nil - end - end - end - - # Use no encoding for session cookies - class Identity - def encode(str); str; end - def decode(str); str; end - end - - class Marshal - def encode(str) - ::Marshal.dump(str) - end - - def decode(str) - ::Marshal.load(str) if str - end - end - - attr_reader :coder, :encryptors - - def initialize(app, options = {}) - secrets = [*options[:secrets]] - - encryptor_opts = { - purpose: options[:key], serialize_json: options[:serialize_json] - } - - # For each secret, create an Encryptor. We have iterate this Array at - # decryption time to achieve key rotation. - @encryptors = secrets.map do |secret| - Rack::Encryptor.new secret, encryptor_opts - end - - # If a legacy HMAC secret is present, initialize those features - if options.has_key?(:legacy_hmac_secret) - @legacy_hmac = options.fetch(:legacy_hmac, 'SHA1') - - @legacy_hmac_secret = options[:legacy_hmac_secret] - @legacy_hmac_coder = options.fetch(:legacy_hmac_coder, Base64::Marshal.new) - else - @legacy_hmac = false - end - - warn <<-MSG unless secure?(options) - SECURITY WARNING: No secret option provided to Rack::Session::Cookie. - This poses a security threat. It is strongly recommended that you - provide a secret to prevent exploits that may be possible from crafted - cookies. This will not be supported in future versions of Rack, and - future versions will even invalidate your existing user cookies. - - Called from: #{caller[0]}. - MSG - - # Potential danger ahead! Marshal without verification and/or - # encryption could present a major security issue. - @coder = options[:coder] ||= Base64::Marshal.new - - super(app, options.merge!(cookie_only: true)) - end - - private - - def find_session(req, sid) - data = unpacked_cookie_data(req) - data = persistent_session_id!(data) - [data["session_id"], data] - end - - def extract_session_id(request) - unpacked_cookie_data(request)["session_id"] - end - - def unpacked_cookie_data(request) - request.fetch_header(RACK_SESSION_UNPACKED_COOKIE_DATA) do |k| - cookie_data = request.cookies[@key] - session_data = nil - - # Try to decrypt the session data with our encryptors - encryptors.each do |encryptor| - begin - session_data = encryptor.decrypt(cookie_data) if cookie_data - break - rescue Rack::Encryptor::Error => error - request.env[Rack::RACK_ERRORS].puts "Session cookie encryptor error: #{error.message}" - - next - end - end - - # If session decryption fails but there is @legacy_hmac_secret - # defined, attempt legacy HMAC verification - if !session_data && @legacy_hmac_secret - # Parse and verify legacy HMAC session cookie - session_data, _, digest = cookie_data.rpartition('--') - session_data = nil unless legacy_digest_match?(session_data, digest) - - # Decode using legacy HMAC decoder - session_data = @legacy_hmac_coder.decode(session_data) - - elsif !session_data && coder - # Use the coder option, which has the potential to be very unsafe - session_data = coder.decode(cookie_data) - end - - request.set_header(k, session_data || {}) - end - end - - def persistent_session_id!(data, sid = nil) - data ||= {} - data["session_id"] ||= sid || generate_sid - data - end - - class SessionId < DelegateClass(Session::SessionId) - attr_reader :cookie_value - - def initialize(session_id, cookie_value) - super(session_id) - @cookie_value = cookie_value - end - end - - def write_session(req, session_id, session, options) - session = session.merge("session_id" => session_id) - session_data = encode_session_data(session) - - if session_data.size > (4096 - @key.size) - req.get_header(RACK_ERRORS).puts("Warning! Rack::Session::Cookie data size exceeds 4K.") - nil - else - SessionId.new(session_id, session_data) - end - end - - def delete_session(req, session_id, options) - # Nothing to do here, data is in the client - generate_sid unless options[:drop] - end - - def legacy_digest_match?(data, digest) - return false unless data && digest - - Rack::Utils.secure_compare(digest, legacy_generate_hmac(data)) - end - - def legacy_generate_hmac(data) - OpenSSL::HMAC.hexdigest(@legacy_hmac, @legacy_hmac_secret, data) - end - - def encode_session_data(session) - if encryptors.empty? - coder.encode(session) - else - encryptors.first.encrypt(session) - end - end - - # Were consider "secure" if: - # * Encrypted cookies are enabled and one or more encryptor is - # initialized - # * The legacy HMAC option is enabled - # * Customer :coder is used, with :let_coder_handle_secure_encoding - # set to true - def secure?(options) - !@encryptors.empty? || - @legacy_hmac || - (options[:coder] && options[:let_coder_handle_secure_encoding]) - end - end - end -end diff --git a/lib/rack/session/memcache.rb b/lib/rack/session/memcache.rb deleted file mode 100644 index 6a601174..00000000 --- a/lib/rack/session/memcache.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -require 'rack/session/dalli' - -module Rack - module Session - warn "Rack::Session::Memcache is deprecated, please use Rack::Session::Dalli from 'dalli' gem instead." - Memcache = Dalli - end -end diff --git a/lib/rack/session/pool.rb b/lib/rack/session/pool.rb deleted file mode 100644 index 05c567d0..00000000 --- a/lib/rack/session/pool.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -# AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net -# THANKS: -# apeiros, for session id generation, expiry setup, and threadiness -# sergio, threadiness and bugreps - -require_relative 'abstract/id' - -module Rack - module Session - # Rack::Session::Pool provides simple cookie based session management. - # Session data is stored in a hash held by @pool. - # In the context of a multithreaded environment, sessions being - # committed to the pool is done in a merging manner. - # - # The :drop option is available in rack.session.options if you wish to - # explicitly remove the session from the session cache. - # - # Example: - # myapp = MyRackApp.new - # sessioned = Rack::Session::Pool.new(myapp, - # :domain => 'foo.com', - # :expire_after => 2592000 - # ) - # Rack::Handler::WEBrick.run sessioned - - class Pool < Abstract::PersistedSecure - attr_reader :mutex, :pool - DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge(drop: false, allow_fallback: true) - - def initialize(app, options = {}) - super - @pool = Hash.new - @mutex = Mutex.new - @allow_fallback = @default_options.delete(:allow_fallback) - end - - def generate_sid(*args, use_mutex: true) - loop do - sid = super(*args) - break sid unless use_mutex ? @mutex.synchronize { @pool.key? sid.private_id } : @pool.key?(sid.private_id) - end - end - - def find_session(req, sid) - @mutex.synchronize do - unless sid and session = get_session_with_fallback(sid) - sid, session = generate_sid(use_mutex: false), {} - @pool.store sid.private_id, session - end - [sid, session] - end - end - - def write_session(req, session_id, new_session, options) - @mutex.synchronize do - @pool.store session_id.private_id, new_session - session_id - end - end - - def delete_session(req, session_id, options) - @mutex.synchronize do - @pool.delete(session_id.public_id) - @pool.delete(session_id.private_id) - generate_sid(use_mutex: false) unless options[:drop] - end - end - - private - - def get_session_with_fallback(sid) - @pool[sid.private_id] || (@pool[sid.public_id] if @allow_fallback) - end - end - end -end diff --git a/rack.gemspec b/rack.gemspec index 999a69a0..44c405d7 100644 --- a/rack.gemspec +++ b/rack.gemspec @@ -39,6 +39,8 @@ Gem::Specification.new do |s| "source_code_uri" => "https://github.com/rack/rack" } + s.add_dependency 'rack-session' + s.add_development_dependency 'minitest', "~> 5.0" s.add_development_dependency 'minitest-sprint' s.add_development_dependency 'minitest-global_expectations' diff --git a/test/spec_encryptor.rb b/test/spec_encryptor.rb deleted file mode 100644 index 7f88661c..00000000 --- a/test/spec_encryptor.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' -require 'rack/encryptor' -require 'rack/utils' - -describe Rack::Encryptor do - def setup - @secret = SecureRandom.random_bytes(64) - end - - it 'initialize does not destroy key string' do - encryptor = Rack::Encryptor.new(@secret) - - @secret.size.must_equal 64 - end - - it 'initialize raises ArgumentError on invalid key' do - lambda { Rack::Encryptor.new [:foo] }.must_raise ArgumentError - end - - it 'initialize raises ArgumentError on short key' do - lambda { Rack::Encryptor.new 'key' }.must_raise ArgumentError - end - - it 'decrypts an encrypted message' do - encryptor = Rack::Encryptor.new(@secret) - - message = encryptor.encrypt(foo: 'bar') - - encryptor.decrypt(message).must_equal foo: 'bar' - end - - it 'decrypt raises InvalidSignature for tampered messages' do - encryptor = Rack::Encryptor.new(@secret) - - message = encryptor.encrypt(foo: 'bar') - - decoded_message = Base64.urlsafe_decode64(message) - tampered_message = Base64.urlsafe_encode64(decoded_message.tap { |m| - m[m.size - 1] = (m[m.size - 1].unpack('C')[0] ^ 1).chr - }) - - lambda { - encryptor.decrypt(tampered_message) - }.must_raise Rack::Encryptor::InvalidSignature - end - - it 'decrypts an encrypted message with purpose' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing') - - message = encryptor.encrypt(foo: 'bar') - - encryptor.decrypt(message).must_equal foo: 'bar' - end - - it 'decrypts raises InvalidSignature without purpose' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing') - other_encryptor = Rack::Encryptor.new(@secret) - - message = other_encryptor.encrypt(foo: 'bar') - - lambda { encryptor.decrypt(message) }.must_raise Rack::Encryptor::InvalidSignature - end - - it 'decrypts raises InvalidSignature with different purpose' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing') - other_encryptor = Rack::Encryptor.new(@secret, purpose: 'other') - - message = other_encryptor.encrypt(foo: 'bar') - - lambda { encryptor.decrypt(message) }.must_raise Rack::Encryptor::InvalidSignature - end - - it 'initialize raises ArgumentError on invalid pad_size' do - lambda { Rack::Encryptor.new @secret, pad_size: :bar }.must_raise ArgumentError - end - - it 'initialize raises ArgumentError on to short pad_size' do - lambda { Rack::Encryptor.new @secret, pad_size: 1 }.must_raise ArgumentError - end - - it 'initialize raises ArgumentError on to long pad_size' do - lambda { Rack::Encryptor.new @secret, pad_size: 8023 }.must_raise ArgumentError - end - - it 'decrypts an encrypted message without pad_size' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing', pad_size: nil) - - message = encryptor.encrypt(foo: 'bar') - - encryptor.decrypt(message).must_equal foo: 'bar' - end - - it 'encryptor with pad_size increases message size' do - no_pad_encryptor = Rack::Encryptor.new(@secret, purpose: 'testing', pad_size: nil) - pad_encryptor = Rack::Encryptor.new(@secret, purpose: 'testing', pad_size: 64) - - message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt('')) - message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt('')) - message_size_diff = message_with.bytesize - message_without.bytesize - - message_with.bytesize.must_be :>, message_without.bytesize - message_size_diff.must_equal 64 - Marshal.dump('').bytesize - 2 - end - - it 'encryptor with pad_size has message payload size to multiple of pad_size' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt(foo: 'bar' * 4) - - decoded_message = Base64.urlsafe_decode64(message) - - # slice 1 byte for version, 32 bytes for cipher_secret, 16 bytes for IV - # from the start of the string and 32 bytes at the end of the string - encrypted_payload = decoded_message[(1 + 32 + 16)..-33] - - (encrypted_payload.bytesize % 24).must_equal 0 - end - - # This test checks the one-time message key IS NOT used as the cipher key. - # Doing so would remove the confidentiality assurances as the key is - # essentially included in plaintext then. - it 'uses a secret cipher key for encryption and decryption' do - cipher = OpenSSL::Cipher.new('aes-256-ctr') - encryptor = Rack::Encryptor.new(@secret) - - message = encryptor.encrypt(foo: 'bar') - raw_message = Base64.urlsafe_decode64(message) - - ver = raw_message.slice!(0, 1) - key = raw_message.slice!(0, 32) - iv = raw_message.slice!(0, 16) - - cipher.decrypt - cipher.key = key - cipher.iv = iv - - data = cipher.update(raw_message) << cipher.final - - # "data" should now be random bytes because we tried to decrypt a message - # with the wrong key - - padding_bytes, = data.unpack('v') # likely a large number - serialized_data = data.slice(2 + padding_bytes, data.bytesize) # likely nil - - lambda { Marshal.load serialized_data }.must_raise TypeError - end - - it 'it calls set_cipher_key with the correct key' do - encryptor = Rack::Encryptor.new(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt(foo: 'bar') - - message_key = Base64.urlsafe_decode64(message).slice(1, 32) - - callable = proc do |cipher, key| - key.wont_equal @secret - key.wont_equal message_key - - cipher.key = key - end - - encryptor.stub :set_cipher_key, callable do - encryptor.decrypt message - end - end -end diff --git a/test/spec_session_abstract_id.rb b/test/spec_session_abstract_id.rb deleted file mode 100644 index 9812b530..00000000 --- a/test/spec_session_abstract_id.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' -### WARNING: there be hax in this file. - -require_relative '../lib/rack/session/abstract/id' -separate_testing do - require_relative '../lib/rack/request' -end - -describe Rack::Session::Abstract::ID do - attr_reader :id - - def setup - super - @id = Rack::Session::Abstract::ID - end - - it "use securerandom" do - assert_equal ::SecureRandom, id::DEFAULT_OPTIONS[:secure_random] - - id = @id.new nil - assert_equal ::SecureRandom, id.sid_secure - end - - it "allow to use another securerandom provider" do - secure_random = Class.new do - def hex(*args) - 'fake_hex' - end - end - id = Rack::Session::Abstract::ID.new nil, secure_random: secure_random.new - id.send(:generate_sid).must_equal 'fake_hex' - end - - it "should warn when subclassing" do - verbose = $VERBOSE - begin - $VERBOSE = true - warn_arg = nil - @id.define_singleton_method(:warn) do |arg| - warn_arg = arg - end - c = Class.new(@id) - regexp = /is inheriting from Rack::Session::Abstract::ID. Inheriting from Rack::Session::Abstract::ID is deprecated, please inherit from Rack::Session::Abstract::Persisted instead/ - warn_arg.must_match(regexp) - - warn_arg = nil - c = Class.new(c) - warn_arg.must_be_nil - ensure - $VERBOSE = verbose - @id.singleton_class.send(:remove_method, :warn) - end - end - - it "#find_session should find session in request" do - id = @id.new(nil) - def id.get_session(env, sid) - [env['rack.session'], generate_sid] - end - req = Rack::Request.new('rack.session' => {}) - session, sid = id.find_session(req, nil) - session.must_equal({}) - sid.must_match(/\A\h+\z/) - end - - it "#write_session should write session to request" do - id = @id.new(nil) - def id.set_session(env, sid, session, options) - [env, sid, session, options] - end - req = Rack::Request.new({}) - id.write_session(req, 1, 2, 3).must_equal [{}, 1, 2, 3] - end - - it "#delete_session should remove session from request" do - id = @id.new(nil) - def id.destroy_session(env, sid, options) - [env, sid, options] - end - req = Rack::Request.new({}) - id.delete_session(req, 1, 2).must_equal [{}, 1, 2] - end -end diff --git a/test/spec_session_abstract_persisted.rb b/test/spec_session_abstract_persisted.rb deleted file mode 100644 index 1b1fce86..00000000 --- a/test/spec_session_abstract_persisted.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' - -require_relative '../lib/rack/session/abstract/id' -separate_testing do - require_relative '../lib/rack/request' -end - -describe Rack::Session::Abstract::Persisted do - def setup - @class = Rack::Session::Abstract::Persisted - @pers = @class.new(nil) - end - - it "#generated_sid generates a session identifier" do - @pers.send(:generate_sid).must_match(/\A\h+\z/) - @pers.send(:generate_sid, nil).must_match(/\A\h+\z/) - - obj = Object.new - def obj.hex(_); raise NotImplementedError end - @pers.send(:generate_sid, obj).must_match(/\A\h+\z/) - end - - it "#commit_session? returns false if :skip option is given" do - @pers.send(:commit_session?, Rack::Request.new({}), {}, skip: true).must_equal false - end - - it "#commit_session writes to rack.errors if session cannot be written" do - @pers = @class.new(nil) - def @pers.write_session(*) end - errors = StringIO.new - env = { 'rack.errors' => errors } - req = Rack::Request.new(env) - store = Class.new do - def load_session(req) - ["id", {}] - end - def session_exists?(req) - true - end - end - session = env['rack.session'] = Rack::Session::Abstract::SessionHash.new(store.new, req) - session['foo'] = 'bar' - @pers.send(:commit_session, req, Rack::Response.new) - errors.rewind - errors.read.must_equal "Warning! Rack::Session::Abstract::Persisted failed to save session. Content dropped.\n" - end - - it "#cookie_value returns its argument" do - obj = Object.new - @pers.send(:cookie_value, obj).must_equal(obj) - end - - it "#session_class returns the default session class" do - @pers.send(:session_class).must_equal Rack::Session::Abstract::SessionHash - end - - it "#find_session raises" do - proc { @pers.send(:find_session, nil, nil) }.must_raise RuntimeError - end - - it "#write_session raises" do - proc { @pers.send(:write_session, nil, nil, nil, nil) }.must_raise RuntimeError - end - - it "#delete_session raises" do - proc { @pers.send(:delete_session, nil, nil, nil) }.must_raise RuntimeError - end -end diff --git a/test/spec_session_abstract_persisted_secure_secure_session_hash.rb b/test/spec_session_abstract_persisted_secure_secure_session_hash.rb deleted file mode 100644 index d6d18860..00000000 --- a/test/spec_session_abstract_persisted_secure_secure_session_hash.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' -require_relative '../lib/rack/session/abstract/id' - -separate_testing do - require_relative '../lib/rack/request' -end - -describe Rack::Session::Abstract::PersistedSecure::SecureSessionHash do - attr_reader :hash - - def setup - super - @store = Class.new do - def load_session(req) - [Rack::Session::SessionId.new("id"), { foo: :bar, baz: :qux }] - end - def session_exists?(req) - true - end - end - @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(@store.new, nil) - end - - it "returns keys" do - assert_equal ["foo", "baz"], hash.keys - end - - it "returns values" do - assert_equal [:bar, :qux], hash.values - end - - describe "#[]" do - it "returns value for a matching key" do - assert_equal :bar, hash[:foo] - end - - it "returns value for a 'session_id' key" do - assert_equal "id", hash['session_id'] - end - - it "returns nil value for missing 'session_id' key" do - store = @store.new - def store.load_session(req) - [nil, {}] - end - @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(store, nil) - assert_nil hash['session_id'] - end - - it "returns value for non SessionId 'session_id' key" do - store = @store.new - def store.load_session(req) - ["id", {}] - end - @hash = Rack::Session::Abstract::PersistedSecure::SecureSessionHash.new(store, nil) - assert_equal "id", hash['session_id'] - end - end - - describe "#fetch" do - it "returns value for a matching key" do - assert_equal :bar, hash.fetch(:foo) - end - - it "works with a default value" do - assert_equal :default, hash.fetch(:unknown, :default) - end - - it "works with a block" do - assert_equal :default, hash.fetch(:unknown) { :default } - end - - it "it raises when fetching unknown keys without defaults" do - lambda { hash.fetch(:unknown) }.must_raise KeyError - end - end - - describe "#stringify_keys" do - it "returns hash or session hash with keys stringified" do - assert_equal({ "foo" => :bar, "baz" => :qux }, hash.send(:stringify_keys, hash).to_h) - end - end -end - diff --git a/test/spec_session_abstract_session_hash.rb b/test/spec_session_abstract_session_hash.rb deleted file mode 100644 index 2a832c1c..00000000 --- a/test/spec_session_abstract_session_hash.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' - -require 'rack/session/abstract/id' -separate_testing do - require_relative '../lib/rack/request' -end - -describe Rack::Session::Abstract::SessionHash do - attr_reader :hash - - def setup - super - store = Class.new do - def load_session(req) - ["id", { foo: :bar, baz: :qux, x: { y: 1 } }] - end - def session_exists?(req) - true - end - end - @class = Rack::Session::Abstract::SessionHash - @hash = @class.new(store.new, nil) - end - - it ".find finds entry in request" do - assert_equal({}, @class.find(Rack::Request.new('rack.session' => {}))) - end - - it ".set sets session in request" do - req = Rack::Request.new({}) - @class.set(req, {}) - req.env['rack.session'].must_equal({}) - end - - it ".set_options sets session options in request" do - req = Rack::Request.new({}) - h = {} - @class.set_options(req, h) - opts = req.env['rack.session.options'] - opts.must_equal(h) - opts.wont_be_same_as(h) - end - - it "#keys returns keys" do - assert_equal ["foo", "baz", "x"], hash.keys - end - - it "#values returns values" do - assert_equal [:bar, :qux, { y: 1 }], hash.values - end - - it "#dig operates like Hash#dig" do - assert_equal({ y: 1 }, hash.dig("x")) - assert_equal(1, hash.dig(:x, :y)) - assert_nil(hash.dig(:z)) - assert_nil(hash.dig(:x, :z)) - lambda { hash.dig(:x, :y, :z) }.must_raise TypeError - lambda { hash.dig }.must_raise ArgumentError - end - - it "#each iterates over entries" do - a = [] - @hash.each do |k, v| - a << [k, v] - end - a.must_equal [["foo", :bar], ["baz", :qux], ["x", { y: 1 }]] - end - - it "#has_key returns whether the key is in the hash" do - assert_equal true, hash.has_key?("foo") - assert_equal true, hash.has_key?(:foo) - assert_equal false, hash.has_key?("food") - assert_equal false, hash.has_key?(:food) - end - - it "#replace replaces hash" do - hash.replace({ bar: "foo" }) - assert_equal "foo", hash["bar"] - end - - describe "#fetch" do - it "returns value for a matching key" do - assert_equal :bar, hash.fetch(:foo) - end - - it "works with a default value" do - assert_equal :default, hash.fetch(:unknown, :default) - end - - it "works with a block" do - assert_equal :default, hash.fetch(:unknown) { :default } - end - - it "it raises when fetching unknown keys without defaults" do - lambda { hash.fetch(:unknown) }.must_raise KeyError - end - end - - it "#stringify_keys returns hash or session hash with keys stringified" do - assert_equal({ "foo" => :bar, "baz" => :qux, "x" => { y: 1 } }, hash.send(:stringify_keys, hash).to_h) - end -end diff --git a/test/spec_session_cookie.rb b/test/spec_session_cookie.rb deleted file mode 100644 index dcb981d3..00000000 --- a/test/spec_session_cookie.rb +++ /dev/null @@ -1,591 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' - -separate_testing do - require_relative '../lib/rack/session/cookie' - require_relative '../lib/rack/response' - require_relative '../lib/rack/lint' - require_relative '../lib/rack/mock' -end - -describe Rack::Session::Cookie do - incrementor = lambda do |env| - env["rack.session"]["counter"] ||= 0 - env["rack.session"]["counter"] += 1 - hash = env["rack.session"].dup - hash.delete("session_id") - Rack::Response.new(hash.inspect).to_a - end - - session_id = lambda do |env| - Rack::Response.new(env["rack.session"].to_hash.inspect).to_a - end - - session_option = lambda do |opt| - lambda do |env| - Rack::Response.new(env["rack.session.options"][opt].inspect).to_a - end - end - - nothing = lambda do |env| - Rack::Response.new("Nothing").to_a - end - - renewer = lambda do |env| - env["rack.session.options"][:renew] = true - Rack::Response.new("Nothing").to_a - end - - only_session_id = lambda do |env| - Rack::Response.new(env["rack.session"]["session_id"].to_s).to_a - end - - bigcookie = lambda do |env| - env["rack.session"]["cookie"] = "big" * 3000 - Rack::Response.new(env["rack.session"].inspect).to_a - end - - destroy_session = lambda do |env| - env["rack.session"].destroy - Rack::Response.new("Nothing").to_a - end - - def response_for(options = {}) - request_options = options.fetch(:request, {}) - cookie = if options[:cookie].is_a?(Rack::Response) - options[:cookie]["Set-Cookie"] - else - options[:cookie] - end - request_options["HTTP_COOKIE"] = cookie || "" - - app_with_cookie = Rack::Session::Cookie.new(*options[:app]) - app_with_cookie = Rack::Lint.new(app_with_cookie) - Rack::MockRequest.new(app_with_cookie).get("/", request_options) - end - - def random_encryptor_secret - SecureRandom.random_bytes(64) - end - - before do - # Random key, as a hex string - @secret = random_encryptor_secret - - @warnings = warnings = [] - Rack::Session::Cookie.class_eval do - define_method(:warn) { |m| warnings << m } - end - end - - after do - Rack::Session::Cookie.class_eval { remove_method :warn } - end - - describe 'Base64' do - it 'uses base64 to encode' do - coder = Rack::Session::Cookie::Base64.new - str = 'fuuuuu' - coder.encode(str).must_equal [str].pack('m0') - end - - it 'uses base64 to decode' do - coder = Rack::Session::Cookie::Base64.new - str = ['fuuuuu'].pack('m0') - coder.decode(str).must_equal str.unpack('m0').first - end - - it 'handles non-strict base64 encoding' do - coder = Rack::Session::Cookie::Base64.new - str = ['A' * 256].pack('m') - coder.decode(str).must_equal 'A' * 256 - end - - describe 'Marshal' do - it 'marshals and base64 encodes' do - coder = Rack::Session::Cookie::Base64::Marshal.new - str = 'fuuuuu' - coder.encode(str).must_equal [::Marshal.dump(str)].pack('m0') - end - - it 'marshals and base64 decodes' do - coder = Rack::Session::Cookie::Base64::Marshal.new - str = [::Marshal.dump('fuuuuu')].pack('m0') - coder.decode(str).must_equal ::Marshal.load(str.unpack('m0').first) - end - - it 'rescues failures on decode' do - coder = Rack::Session::Cookie::Base64::Marshal.new - coder.decode('lulz').must_be_nil - end - end - - describe 'JSON' do - it 'JSON and base64 encodes' do - coder = Rack::Session::Cookie::Base64::JSON.new - obj = %w[fuuuuu] - coder.encode(obj).must_equal [::JSON.dump(obj)].pack('m0') - end - - it 'JSON and base64 decodes' do - coder = Rack::Session::Cookie::Base64::JSON.new - str = [::JSON.dump(%w[fuuuuu])].pack('m0') - coder.decode(str).must_equal ::JSON.parse(str.unpack('m0').first) - end - - it 'rescues failures on decode' do - coder = Rack::Session::Cookie::Base64::JSON.new - coder.decode('lulz').must_be_nil - end - end - - describe 'ZipJSON' do - it 'jsons, deflates, and base64 encodes' do - coder = Rack::Session::Cookie::Base64::ZipJSON.new - obj = %w[fuuuuu] - json = JSON.dump(obj) - coder.encode(obj).must_equal [Zlib::Deflate.deflate(json)].pack('m0') - end - - it 'base64 decodes, inflates, and decodes json' do - coder = Rack::Session::Cookie::Base64::ZipJSON.new - obj = %w[fuuuuu] - json = JSON.dump(obj) - b64 = [Zlib::Deflate.deflate(json)].pack('m0') - coder.decode(b64).must_equal obj - end - - it 'rescues failures on decode' do - coder = Rack::Session::Cookie::Base64::ZipJSON.new - coder.decode('lulz').must_be_nil - end - end - end - - it "warns if no secret is given" do - Rack::Session::Cookie.new(incrementor) - @warnings.first.must_match(/no secret/i) - @warnings.clear - Rack::Session::Cookie.new(incrementor, secrets: @secret) - @warnings.must_be :empty? - end - - it 'abort if secret is too short' do - lambda { - Rack::Session::Cookie.new(incrementor, secrets: @secret[0, 16]) - }.must_raise ArgumentError - end - - it "doesn't warn if coder is configured to handle encoding" do - Rack::Session::Cookie.new( - incrementor, - coder: Object.new, - let_coder_handle_secure_encoding: true) - @warnings.must_be :empty? - end - - it "still warns if coder is not set" do - Rack::Session::Cookie.new( - incrementor, - let_coder_handle_secure_encoding: true) - @warnings.first.must_match(/no secret/i) - end - - it 'uses a coder' do - identity = Class.new { - attr_reader :calls - - def initialize - @calls = [] - end - - def encode(str); @calls << :encode; str; end - def decode(str); @calls << :decode; str; end - }.new - response = response_for(app: [incrementor, { coder: identity }]) - - response["Set-Cookie"].must_include "rack.session=" - response.body.must_equal '{"counter"=>1}' - identity.calls.must_equal [:decode, :encode] - end - - it "creates a new cookie" do - response = response_for(app: incrementor) - response["Set-Cookie"].must_include "rack.session=" - response.body.must_equal '{"counter"=>1}' - end - - it "passes through same_site option to session cookie" do - response = response_for(app: [incrementor, same_site: :none]) - response["Set-Cookie"].must_include "SameSite=None" - end - - it "allows using a lambda to specify same_site option, because some browsers require different settings" do - # Details of why this might need to be set dynamically: - # https://www.chromium.org/updates/same-site/incompatible-clients - # https://gist.github.com/bnorton/7dee72023787f367c48b3f5c2d71540f - - response = response_for(app: [incrementor, same_site: lambda { |req, res| :none }]) - response["Set-Cookie"].must_include "SameSite=None" - - response = response_for(app: [incrementor, same_site: lambda { |req, res| :lax }]) - response["Set-Cookie"].must_include "SameSite=Lax" - end - - it "loads from a cookie" do - response = response_for(app: incrementor) - - response = response_for(app: incrementor, cookie: response) - response.body.must_equal '{"counter"=>2}' - - response = response_for(app: incrementor, cookie: response) - response.body.must_equal '{"counter"=>3}' - end - - it "renew session id" do - response = response_for(app: incrementor) - cookie = response['Set-Cookie'] - response = response_for(app: only_session_id, cookie: cookie) - cookie = response['Set-Cookie'] if response['Set-Cookie'] - - response.body.wont_equal "" - old_session_id = response.body - - response = response_for(app: renewer, cookie: cookie) - cookie = response['Set-Cookie'] if response['Set-Cookie'] - response = response_for(app: only_session_id, cookie: cookie) - - response.body.wont_equal "" - response.body.wont_equal old_session_id - end - - it "destroys session" do - response = response_for(app: incrementor) - response = response_for(app: only_session_id, cookie: response) - - response.body.wont_equal "" - old_session_id = response.body - - response = response_for(app: destroy_session, cookie: response) - response = response_for(app: only_session_id, cookie: response) - - response.body.wont_equal "" - response.body.wont_equal old_session_id - end - - it "survives broken cookies" do - response = response_for( - app: incrementor, - cookie: "rack.session=blarghfasel" - ) - response.body.must_equal '{"counter"=>1}' - - response = response_for( - app: [incrementor, { secrets: @secret }], - cookie: "rack.session=" - ) - response.body.must_equal '{"counter"=>1}' - end - - it "barks on too big cookies" do - lambda{ - response_for(app: bigcookie, request: { fatal: true }) - }.must_raise Rack::MockRequest::FatalWarning - end - - it "loads from a cookie with encryption" do - app = [incrementor, { secrets: @secret }] - - response = response_for(app: app) - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' - - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>3}' - - app = [incrementor, { secrets: random_encryptor_secret }] - - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>1}' - end - - it "loads from a cookie with accept-only integrity hash for graceful key rotation" do - response = response_for(app: [incrementor, { secrets: @secret }]) - - new_secret = random_encryptor_secret - - app = [incrementor, { secrets: [new_secret, @secret] }] - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' - - newer_secret = random_encryptor_secret - - app = [incrementor, { secrets: [newer_secret, new_secret] }] - response = response_for(app: app, cookie: response) - - response.body.must_equal '{"counter"=>3}' - end - - it 'loads from a legacy hmac cookie' do - legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) - legacy_secret = 'test legacy secret' - legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session) - - legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" - - app = [incrementor, { secrets: @secret, legacy_hmac_secret: legacy_secret }] - response = response_for(app: app, cookie: legacy_cookie) - response.body.must_equal '{"counter"=>2}' - end - - it "ignores tampered session cookies" do - app = [incrementor, { secrets: @secret }] - - response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' - - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' - - encoded_cookie = response["Set-Cookie"].split('=', 2).last.split(';').first - decoded_cookie = Base64.urlsafe_decode64(Rack::Utils.unescape(encoded_cookie)) - - tampered_cookie = "rack.session=#{Base64.urlsafe_encode64(decoded_cookie.tap { |m| - m[m.size - 1] = (m[m.size - 1].unpack('C')[0] ^ 1).chr - })}" - - response = response_for(app: app, cookie: tampered_cookie) - response.body.must_equal '{"counter"=>1}' - end - - it 'rejects session cookie with different purpose' do - app = [incrementor, { secrets: @secrets }] - other_app = [incrementor, { secrets: @secrets, key: 'other' }] - - response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' - - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' - - response = response_for(app: other_app, cookie: response) - response.body.must_equal '{"counter"=>1}' - end - - it 'adds to RACK_ERRORS on encryptor errors' do - echo_rack_errors = lambda do |env| - env["rack.session"]["counter"] ||= 0 - env["rack.session"]["counter"] += 1 - Rack::Response.new(env[Rack::RACK_ERRORS].flush.tap(&:rewind).read).to_a - end - - app = [incrementor, { secrets: @secret }] - err_app = [echo_rack_errors, { secrets: @secret }] - - response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' - - encoded_cookie = response["Set-Cookie"].split('=', 2).last.split(';').first - decoded_cookie = Base64.urlsafe_decode64(Rack::Utils.unescape(encoded_cookie)) - - tampered_cookie = "rack.session=#{Base64.urlsafe_encode64(decoded_cookie.tap { |m| - m[m.size - 1] = "\0" - })}" - - response = response_for(app: err_app, cookie: tampered_cookie) - response.body.must_equal "Session cookie encryptor error: HMAC is invalid\n" - end - - it 'ignores tampered with legacy hmac cookie' do - legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) - legacy_secret = 'test legacy secret' - legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session).reverse - - legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" - - app = [incrementor, { secret: @secret, legacy_hmac_secret: legacy_secret }] - response = response_for(app: app, cookie: legacy_cookie) - response.body.must_equal '{"counter"=>1}' - end - - it "supports custom digest instance for legacy hmac cookie" do - legacy_hmac = 'SHA256' - legacy_session = Rack::Session::Cookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) - legacy_secret = 'test legacy secret' - legacy_digest = OpenSSL::HMAC.hexdigest(legacy_hmac, legacy_secret, legacy_session) - legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" - - app = [incrementor, { - secrets: @secret, legacy_hmac_secret: legacy_secret, legacy_hmac: legacy_hmac - }] - - response = response_for(app: app, cookie: legacy_cookie) - response.body.must_equal '{"counter"=>2}' - - response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>3}' - end - - it "can handle Rack::Lint middleware" do - response = response_for(app: incrementor) - - lint = Rack::Lint.new(session_id) - response = response_for(app: lint, cookie: response) - response.body.wont_be :nil? - end - - it "can handle middleware that inspects the env" do - class TestEnvInspector - def initialize(app) - @app = app - end - def call(env) - env.inspect - @app.call(env) - end - end - - response = response_for(app: incrementor) - - inspector = TestEnvInspector.new(session_id) - response = response_for(app: inspector, cookie: response) - response.body.wont_be :nil? - end - - it "returns the session id in the session hash" do - response = response_for(app: incrementor) - response.body.must_equal '{"counter"=>1}' - - response = response_for(app: session_id, cookie: response) - response.body.must_match(/"session_id"=>/) - response.body.must_match(/"counter"=>1/) - end - - it "does not return a cookie if set to secure but not using ssl" do - app = [incrementor, { secure: true }] - - response = response_for(app: app) - response["Set-Cookie"].must_be_nil - - response = response_for(app: app, request: { "HTTPS" => "on" }) - response["Set-Cookie"].wont_be :nil? - response["Set-Cookie"].must_match(/secure/) - end - - it "does not return a cookie if cookie was not read/written" do - response = response_for(app: nothing) - response["Set-Cookie"].must_be_nil - end - - it "does not return a cookie if cookie was not written (only read)" do - response = response_for(app: session_id) - response["Set-Cookie"].must_be_nil - end - - it "returns even if not read/written if :expire_after is set" do - app = [nothing, { expire_after: 3600 }] - request = { "rack.session" => { "not" => "empty" } } - response = response_for(app: app, request: request) - response["Set-Cookie"].wont_be :nil? - end - - it "returns no cookie if no data was written and no session was created previously, even if :expire_after is set" do - app = [nothing, { expire_after: 3600 }] - response = response_for(app: app) - response["Set-Cookie"].must_be_nil - end - - it "exposes :secrets in env['rack.session.option']" do - response = response_for(app: [session_option[:secrets], { secrets: @secret }]) - response.body.must_equal @secret.inspect - end - - it "exposes :coder in env['rack.session.option']" do - response = response_for(app: session_option[:coder]) - response.body.must_match(/Base64::Marshal/) - end - - it 'exposes correct :coder when a secrets is used' do - response = response_for(app: session_option[:coder], secrets: @secret) - response.body.must_match(/Marshal/) - end - - it "allows passing in a hash with session data from middleware in front" do - request = { 'rack.session' => { foo: 'bar' } } - response = response_for(app: session_id, request: request) - response.body.must_match(/foo/) - end - - it "allows modifying session data with session data from middleware in front" do - request = { 'rack.session' => { foo: 'bar' } } - response = response_for(app: incrementor, request: request) - response.body.must_match(/counter/) - response.body.must_match(/foo/) - end - - it "allows more than one '--' in the cookie when calculating legacy digests" do - @counter = 0 - app = lambda do |env| - env["rack.session"]["message"] ||= "" - env["rack.session"]["message"] += "#{(@counter += 1).to_s}--" - hash = env["rack.session"].dup - hash.delete("session_id") - Rack::Response.new(hash["message"]).to_a - end - - # another example of an unsafe coder is Base64.urlsafe_encode64 - unsafe_coder = Class.new { - def encode(hash); hash.inspect end - def decode(str); eval(str) if str; end - }.new - - legacy_session = unsafe_coder.encode('message' => "#{@counter += 1}--#{@counter += 1}--", 'session_id' => 'abcdef') - legacy_secret = 'test legacy secret' - legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, legacy_secret, legacy_session) - legacy_cookie = "rack.session=#{Rack::Utils.escape legacy_session}--#{legacy_digest}; path=/; HttpOnly" - - _app = [ app, { - secrets: @secret, - legacy_hmac_secret: legacy_secret, - legacy_hmac_coder: unsafe_coder - }] - - response = response_for(app: _app, cookie: legacy_cookie) - response.body.must_equal "1--2--3--" - end - - it 'allows for non-strict encoded cookie' do - long_session_app = lambda do |env| - env['rack.session']['value'] = 'A' * 256 - env['rack.session']['counter'] = 1 - hash = env["rack.session"].dup - hash.delete("session_id") - Rack::Response.new(hash.inspect).to_a - end - - non_strict_coder = Class.new { - def encode(str) - [Marshal.dump(str)].pack('m') - end - - def decode(str) - return unless str - - Marshal.load(str.unpack('m').first) - end - }.new - - non_strict_response = response_for(app: [ - long_session_app, { coder: non_strict_coder } - ]) - - response = response_for(app: [ - incrementor - ], cookie: non_strict_response) - - response.body.must_match %Q["value"=>"#{'A' * 256}"] - response.body.must_match '"counter"=>2' - response.body.must_match(/\A{[^}]+}\z/) - end -end diff --git a/test/spec_session_pool.rb b/test/spec_session_pool.rb deleted file mode 100644 index 02b196d3..00000000 --- a/test/spec_session_pool.rb +++ /dev/null @@ -1,288 +0,0 @@ -# frozen_string_literal: true - -require_relative 'helper' - -separate_testing do - require_relative '../lib/rack/session/pool' - require_relative '../lib/rack/response' - require_relative '../lib/rack/lint' - require_relative '../lib/rack/mock' - require_relative '../lib/rack/utils' -end - -describe Rack::Session::Pool do - session_key = Rack::Session::Pool::DEFAULT_OPTIONS[:key] - session_match = /#{session_key}=([0-9a-fA-F]+);/ - - incrementor = lambda do |env| - env["rack.session"]["counter"] ||= 0 - env["rack.session"]["counter"] += 1 - Rack::Response.new(env["rack.session"].inspect).to_a - end - - get_session_id = Rack::Lint.new(lambda do |env| - Rack::Response.new(env["rack.session"].inspect).to_a - end) - - nothing = Rack::Lint.new(lambda do |env| - Rack::Response.new("Nothing").to_a - end) - - drop_session = Rack::Lint.new(lambda do |env| - env['rack.session.options'][:drop] = true - incrementor.call(env) - end) - - renew_session = Rack::Lint.new(lambda do |env| - env['rack.session.options'][:renew] = true - incrementor.call(env) - end) - - defer_session = Rack::Lint.new(lambda do |env| - env['rack.session.options'][:defer] = true - incrementor.call(env) - end) - - incrementor = Rack::Lint.new(incrementor) - - it "creates a new cookie" do - pool = Rack::Session::Pool.new(incrementor) - res = Rack::MockRequest.new(pool).get("/") - res["Set-Cookie"].must_match(session_match) - res.body.must_equal '{"counter"=>1}' - end - - it "determines session from a cookie" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - cookie = req.get("/")["Set-Cookie"] - req.get("/", "HTTP_COOKIE" => cookie). - body.must_equal '{"counter"=>2}' - req.get("/", "HTTP_COOKIE" => cookie). - body.must_equal '{"counter"=>3}' - end - - it "survives nonexistent cookies" do - pool = Rack::Session::Pool.new(incrementor) - res = Rack::MockRequest.new(pool). - get("/", "HTTP_COOKIE" => "#{session_key}=blarghfasel") - res.body.must_equal '{"counter"=>1}' - end - - it "does not send the same session id if it did not change" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - - res0 = req.get("/") - cookie = res0["Set-Cookie"][session_match] - res0.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 1 - - res1 = req.get("/", "HTTP_COOKIE" => cookie) - res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>2}' - pool.pool.size.must_equal 1 - - res2 = req.get("/", "HTTP_COOKIE" => cookie) - res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>3}' - pool.pool.size.must_equal 1 - end - - it "deletes cookies with :drop option" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - drop = Rack::Utils::Context.new(pool, drop_session) - dreq = Rack::MockRequest.new(drop) - - res1 = req.get("/") - session = (cookie = res1["Set-Cookie"])[session_match] - res1.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 1 - - res2 = dreq.get("/", "HTTP_COOKIE" => cookie) - res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>2}' - pool.pool.size.must_equal 0 - - res3 = req.get("/", "HTTP_COOKIE" => cookie) - res3["Set-Cookie"][session_match].wont_equal session - res3.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 1 - end - - it "provides new session id with :renew option" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - renew = Rack::Utils::Context.new(pool, renew_session) - rreq = Rack::MockRequest.new(renew) - - res1 = req.get("/") - session = (cookie = res1["Set-Cookie"])[session_match] - res1.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 1 - - res2 = rreq.get("/", "HTTP_COOKIE" => cookie) - new_cookie = res2["Set-Cookie"] - new_session = new_cookie[session_match] - new_session.wont_equal session - res2.body.must_equal '{"counter"=>2}' - pool.pool.size.must_equal 1 - - res3 = req.get("/", "HTTP_COOKIE" => new_cookie) - res3.body.must_equal '{"counter"=>3}' - pool.pool.size.must_equal 1 - - res4 = req.get("/", "HTTP_COOKIE" => cookie) - res4.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 2 - end - - it "omits cookie with :defer option" do - pool = Rack::Session::Pool.new(incrementor) - defer = Rack::Utils::Context.new(pool, defer_session) - dreq = Rack::MockRequest.new(defer) - - res1 = dreq.get("/") - res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>1}' - pool.pool.size.must_equal 1 - end - - it "can read the session with the legacy id" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - - res0 = req.get("/") - cookie = res0["Set-Cookie"] - session_id = Rack::Session::SessionId.new cookie[session_match, 1] - ses0 = pool.pool[session_id.private_id] - pool.pool[session_id.public_id] = ses0 - pool.pool.delete(session_id.private_id) - - res1 = req.get("/", "HTTP_COOKIE" => cookie) - res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>2}' - pool.pool[session_id.private_id].wont_be_nil - end - - it "cannot read the session with the legacy id if allow_fallback: false option is used" do - pool = Rack::Session::Pool.new(incrementor, allow_fallback: false) - req = Rack::MockRequest.new(pool) - - res0 = req.get("/") - cookie = res0["Set-Cookie"] - session_id = Rack::Session::SessionId.new cookie[session_match, 1] - ses0 = pool.pool[session_id.private_id] - pool.pool[session_id.public_id] = ses0 - pool.pool.delete(session_id.private_id) - - res1 = req.get("/", "HTTP_COOKIE" => cookie) - res1["Set-Cookie"].wont_be_nil - res1.body.must_equal '{"counter"=>1}' - end - - it "drops the session in the legacy id as well" do - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - drop = Rack::Utils::Context.new(pool, drop_session) - dreq = Rack::MockRequest.new(drop) - - res0 = req.get("/") - cookie = res0["Set-Cookie"] - session_id = Rack::Session::SessionId.new cookie[session_match, 1] - ses0 = pool.pool[session_id.private_id] - pool.pool[session_id.public_id] = ses0 - pool.pool.delete(session_id.private_id) - - res2 = dreq.get("/", "HTTP_COOKIE" => cookie) - res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>2}' - pool.pool[session_id.private_id].must_be_nil - pool.pool[session_id.public_id].must_be_nil - end - - it "passes through same_site option to session pool" do - pool = Rack::Session::Pool.new(incrementor, same_site: :none) - req = Rack::MockRequest.new(pool) - res = req.get("/") - res["Set-Cookie"].must_include "SameSite=None" - end - - it "allows using a lambda to specify same_site option, because some browsers require different settings" do - pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :none }) - req = Rack::MockRequest.new(pool) - res = req.get("/") - res["Set-Cookie"].must_include "SameSite=None" - - pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :lax }) - req = Rack::MockRequest.new(pool) - res = req.get("/") - res["Set-Cookie"].must_include "SameSite=Lax" - end - - # anyone know how to do this better? - it "should merge sessions when multithreaded" do - unless $DEBUG - 1.must_equal 1 - next - end - - warn 'Running multithread tests for Session::Pool' - pool = Rack::Session::Pool.new(incrementor) - req = Rack::MockRequest.new(pool) - - res = req.get('/') - res.body.must_equal '{"counter"=>1}' - cookie = res["Set-Cookie"] - sess_id = cookie[/#{pool.key}=([^,;]+)/, 1] - - delta_incrementor = lambda do |env| - # emulate disconjoinment of threading - env['rack.session'] = env['rack.session'].dup - Thread.stop - env['rack.session'][(Time.now.usec * rand).to_i] = true - incrementor.call(env) - end - tses = Rack::Utils::Context.new pool, delta_incrementor - treq = Rack::MockRequest.new(tses) - tnum = rand(7).to_i + 5 - r = Array.new(tnum) do - Thread.new(treq) do |run| - run.get('/', "HTTP_COOKIE" => cookie) - end - end.reverse.map{|t| t.run.join.value } - r.each do |resp| - resp['Set-Cookie'].must_equal cookie - resp.body.must_include '"counter"=>2' - end - - session = pool.pool[sess_id] - session.size.must_equal tnum + 1 # counter - session['counter'].must_equal 2 # meeeh - end - - it "does not return a cookie if cookie was not read/written" do - app = Rack::Session::Pool.new(nothing) - res = Rack::MockRequest.new(app).get("/") - res["Set-Cookie"].must_be_nil - end - - it "does not return a cookie if cookie was not written (only read)" do - app = Rack::Session::Pool.new(get_session_id) - res = Rack::MockRequest.new(app).get("/") - res["Set-Cookie"].must_be_nil - end - - it "returns even if not read/written if :expire_after is set" do - app = Rack::Session::Pool.new(nothing, expire_after: 3600) - res = Rack::MockRequest.new(app).get("/", 'rack.session' => { 'not' => 'empty' }) - res["Set-Cookie"].wont_be :nil? - end - - it "returns no cookie if no data was written and no session was created previously, even if :expire_after is set" do - app = Rack::Session::Pool.new(nothing, expire_after: 3600) - res = Rack::MockRequest.new(app).get("/") - res["Set-Cookie"].must_be_nil - end -end |