Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 72 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,76 @@
# Redis Session Store

[![Code Climate](https://codeclimate.com/github/roidrage/redis-session-store.svg)](https://codeclimate.com/github/roidrage/redis-session-store)
[![Gem Version](https://badge.fury.io/rb/redis-session-store.svg)](http://badge.fury.io/rb/redis-session-store)

A simple Redis-based session store for Rails. But why, you ask,
when there's [redis-store](https://github.com/jodosha/redis-store/)?
redis-store is a one-size-fits-all solution, and I found it not to work
properly with Rails, mostly due to a problem that seemed to lie in
Rack's `Abstract::ID` class. I wanted something that worked, so I
blatantly stole the code from Rails' `MemCacheStore` and turned it
into a Redis version. No support for fancy stuff like distributed
storage across several Redis instances. Feel free to add what you
see fit.

This library doesn't offer anything related to caching, and is
only suitable for Rails applications. For other frameworks or
drop-in support for caching, check out
[redis-store](https://github.com/jodosha/redis-store/).
This is a forked version of the [redis-session-store](https://github.com/roidrage/redis-session-store) gem. It incorporates a few changes:

## Installation
* Configuring usage of a Redis connection pool.
* Passing the `nx: true` option when writing a new session to avoid session collisions.
* Supporting the migration towards hashed session identifiers to more fully address [GHSA-hrqr-hxpp-chr3](https://github.com/advisories/GHSA-hrqr-hxpp-chr3).
* Removes calling `exists` in Redis to check whether a session exists and instead relying on the result of `get`.

For Rails 3+, adding this to your `Gemfile` will do the trick.
## Installation

``` ruby
gem 'redis-session-store'
gem 'redis-session-store', git: 'https://github.com/18F/redis-session-store.git', tag: 'v1.0.1-18f'
```

## Migrating from Rack::Session::SessionId#public_id to Rack::Session::SessionId#private_id

[GHSA-hrqr-hxpp-chr3](https://github.com/advisories/GHSA-hrqr-hxpp-chr3) describes a vulnerability to a timing attack when a key used by the backing store is the same key presented to the client. `redis-session-store` (as of the most recent version 0.11.5) writes the same key to Redis as is presented to the client, typically in the cookie. To allow for a backwards and forwards-compatible zero-downtime migration from `redis-session-store` and using `Rack::Session::SessionId#private_id` to remediate the vulnerability, this forked version provides configuration options to read and write the two versions of the session identifier. A migration path would typically look like:

1. Deploying with the following configuration, which is backwards-compatible:

```ruby
Rails.application.config.session_store :redis_session_store,
# ...
redis: {
# ...
read_public_id: true,
write_public_id: true,
read_private_id: false,
write_private_id: false,
}
```

2. Enabling writing to the private\_id key

```ruby
Rails.application.config.session_store :redis_session_store,
# ...
redis: {
# ...
read_public_id: true,
write_public_id: true,
read_private_id: false,
write_private_id: true,
}
```

3. Enabling reading the private\_id key and disabling reading the public\_id key

```ruby
Rails.application.config.session_store :redis_session_store,
# ...
redis: {
# ...
read_public_id: false,
write_public_id: true,
read_private_id: true,
write_private_id: true,
}
```

4. Disabling writing for the public\_id key

```ruby
Rails.application.config.session_store :redis_session_store,
# ...
redis: {
# ...
read_public_id: false,
write_public_id: false,
read_private_id: true,
write_private_id: true,
}
```

## Configuration
Expand All @@ -34,9 +81,9 @@ In your Rails app, throw in an initializer with the following contents:
``` ruby
Rails.application.config.session_store :redis_session_store,
key: 'your_session_key',
expire_after: nil, # cookie expiration
redis: {
expire_after: 120.minutes, # cookie expiration
ttl: 120.minutes, # Redis expiration, defaults to 'expire_after'
ttl: 120.minutes, # Redis expiration
key_prefix: 'myapp:session:',
url: 'redis://localhost:6379/0',
}
Expand All @@ -61,23 +108,19 @@ Rails.application.config.session_store :redis_session_store,
By default the Marshal serializer is used. With Rails 4, you can use JSON as a
custom serializer:

* `:json` - serialize cookie values with `JSON` (Requires Rails 4+)
* `:marshal` - serialize cookie values with `Marshal` (Default)
* `:hybrid` - transparently migrate existing `Marshal` cookie values to `JSON` (Requires Rails 4+)
* `CustomClass` - You can just pass the constant name of any class that responds to `.load` and `.dump`
* `:json` - serialize cookie values with `JSON`
* `CustomClass` - You can just pass the constant name of a class that responds to `.load` and `.dump`

``` ruby
Rails.application.config.session_store :redis_session_store,
# ... other options ...
serializer: :hybrid
serializer: :json
redis: {
# ... redis options ...
}
```

**Note**: Rails 4 is required for using the `:json` and `:hybrid` serializers
because the `Flash` object doesn't serialize well in 3.2. See [Rails #13945](https://github.com/rails/rails/pull/13945) for more info.

### Session load error handling

If you want to handle cases where the session data cannot be loaded, a
Expand All @@ -95,19 +138,6 @@ Rails.application.config.session_store :redis_session_store,

**Note** The session will *always* be destroyed when it cannot be loaded.

### Other notes

It returns with_indifferent_access if ActiveSupport is defined.

## Rails 2 Compatibility

This gem is currently only compatible with Rails 3+. If you need
Rails 2 compatibility, be sure to pin to a lower version like so:

``` ruby
gem 'redis-session-store', '< 0.3'
```

## Contributing, Authors, & License

See [CONTRIBUTING.md](CONTRIBUTING.md), [AUTHORS.md](AUTHORS.md), and
Expand Down
82 changes: 52 additions & 30 deletions lib/redis-session-store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
# Redis session storage for Rails, and for Rails only. Derived from
# the MemCacheStore code, simply dropping in Redis instead.
class RedisSessionStore < ActionDispatch::Session::AbstractSecureStore
VERSION = '1.0.0-18f'.freeze
VERSION = '1.0.1-18f'.freeze

# ==== Options
# * +:key+ - Same as with the other cookie stores, key name
# * +:redis+ - A hash with redis-specific options
# * +:url+ - Redis url, default is redis://localhost:6379/0
# * +:key_prefix+ - Prefix for keys used in Redis, e.g. +myapp:+
# * +:ttl+ - Default Redis TTL for sessions
# * +:expire_after+ - A number in seconds for session timeout
# * +:client+ - Connect to Redis with given object rather than create one
# * +:client_pool:+ - Connect to Redis with a ConnectionPool
# * +:on_redis_down:+ - Called with err, env, and SID on Errno::ECONNREFUSED
Expand All @@ -25,7 +24,7 @@ class RedisSessionStore < ActionDispatch::Session::AbstractSecureStore
# Rails.application.config.session_store :redis_session_store,
# key: 'your_session_key',
# redis: {
# expire_after: 120.minutes,
# ttl: 120.minutes,
# key_prefix: 'myapp:session:',
# url: 'redis://localhost:6379/0'
# },
Expand All @@ -48,16 +47,27 @@ def initialize(app, options = {})
@serializer = determine_serializer(options[:serializer])
@on_session_load_error = options[:on_session_load_error]
@default_redis_ttl = redis_options[:ttl]
@write_fallback = redis_options[:write_fallback]
@read_fallback = redis_options[:read_fallback]
@read_private_id = redis_options.fetch(:read_private_id, true)
@write_private_id = redis_options.fetch(:write_private_id, true)
@read_public_id = redis_options[:read_public_id]
@write_public_id = redis_options[:write_public_id]
verify_handlers!

if !write_private_id && !write_public_id
raise ArgumentError, "write_public_id and write_private_id cannot both be false"
end

if !read_private_id && !read_public_id
raise ArgumentError, "read_public_id and read_private_id cannot both be false"
end
end

attr_accessor :on_redis_down, :on_session_load_error

private

attr_reader :redis_pool, :single_redis, :key, :default_options, :serializer, :default_redis_ttl, :read_fallback, :write_fallback
attr_reader :redis_pool, :single_redis, :key, :default_options, :serializer, :default_redis_ttl,
:read_private_id, :write_private_id, :read_public_id, :write_public_id

def verify_handlers!
%w(on_redis_down on_session_load_error).each do |h|
Expand All @@ -75,7 +85,7 @@ def prefixed(sid)
"#{default_options[:key_prefix]}#{private_id}"
end

def prefixed_fallback(sid)
def prefixed_public_id(sid)
return nil unless sid
"#{default_options[:key_prefix]}#{sid}"
end
Expand All @@ -97,11 +107,13 @@ def find_session(req, sid)

def load_session_from_redis(redis_connection, req, sid)
return nil unless sid
if read_fallback
data = redis_connection.get(prefixed(sid)) || redis_connection.get(prefixed_fallback(sid))
else
data = redis_connection.get(prefixed(sid))
end
data = if read_private_id && !read_public_id
redis_connection.get(prefixed(sid))
elsif read_private_id && read_public_id
redis_connection.get(prefixed(sid)) || redis_connection.get(prefixed_public_id(sid))
elsif !read_private_id && read_public_id
redis_connection.get(prefixed_public_id(sid))
end

begin
data ? decode(data) : nil
Expand All @@ -120,33 +132,43 @@ def decode(data)
def write_session(req, sid, session_data, options = nil)
return false unless sid

if write_fallback
key = prefixed_fallback(sid)
else
key = prefixed(sid)
end
key = prefixed(sid)
return false unless key

expiry = options[:expire_after] || default_redis_ttl
new_session = req.env['redis_session_store.new_session']
encoded_data = encode(session_data)

result = if write_private_id && !write_public_id
write_redis_session(key, encoded_data, expiry: expiry, new_session: new_session)
elsif write_public_id && write_private_id
public_id_key = prefixed_public_id(sid)
write_redis_session(public_id_key, encoded_data, expiry: expiry, new_session: new_session)
write_redis_session(key, encoded_data, expiry: expiry, new_session: new_session)
elsif write_public_id && !write_private_id
public_id_key = prefixed_public_id(sid)
write_redis_session(public_id_key, encoded_data, expiry: expiry, new_session: new_session)
end

if result
sid
else
false
end
end

def write_redis_session(key, data, expiry: nil, new_session: false)
result = with_redis_connection(default_rescue_value: false) do |redis_connection|
if expiry && new_session
redis_connection.set(key, encode(session_data), ex: expiry, nx: true)
redis_connection.set(key, data, ex: expiry, nx: true)
elsif expiry
redis_connection.set(key, encode(session_data), ex: expiry)
redis_connection.set(key, data, ex: expiry)
elsif new_session
redis_connection.set(key, encode(session_data), nx: true)
redis_connection.set(key, data, nx: true)
else
redis_connection.set(key, encode(session_data))
redis_connection.set(key, data)
end
end

if result
sid
else
false
end
end

def encode(session_data)
Expand All @@ -165,9 +187,9 @@ def delete_session(req, sid, options)
end

def delete_session_from_redis(redis_connection, sid, req, options)
if write_fallback
fallback_key = prefixed_fallback(sid)
redis_connection.del(fallback_key) if fallback_key
if write_public_id || read_public_id
public_id_key = prefixed_public_id(sid)
redis_connection.del(public_id_key) if public_id_key
end

key = prefixed(sid)
Expand Down
Loading