Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypted session cookies #1324

Merged
merged 3 commits into from
Jul 15, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/sinatra/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1772,7 +1772,7 @@ def force_encoding(*args) settings.force_encoding(*args) end
set :dump_errors, Proc.new { !test? }
set :show_exceptions, Proc.new { development? }
set :sessions, false
set :session_store, Rack::Session::Cookie
set :session_store, Rack::Protection::EncryptedCookie
set :logging, false
set :protection, true
set :method_override, false
Expand Down
2 changes: 2 additions & 0 deletions rack-protection/lib/rack/protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module Protection
autoload :Base, 'rack/protection/base'
autoload :CookieTossing, 'rack/protection/cookie_tossing'
autoload :ContentSecurityPolicy, 'rack/protection/content_security_policy'
autoload :Encryptor, 'rack/protection/encryptor'
autoload :EncryptedCookie, 'rack/protection/encrypted_cookie'
autoload :EscapedParams, 'rack/protection/escaped_params'
autoload :FormToken, 'rack/protection/form_token'
autoload :FrameOptions, 'rack/protection/frame_options'
Expand Down
260 changes: 260 additions & 0 deletions rack-protection/lib/rack/protection/encrypted_cookie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
require 'openssl'
require 'zlib'
require 'json'
require 'rack/request'
require 'rack/response'
require 'rack/session/abstract/id'

module Rack
module Protection
# Rack::Protection::EncryptedCookie provides simple cookie based session management.
# By default, the session is a Ruby Hash stored as base64 encoded marshalled
# data set to :key (default: rack.session). The object that encodes the
# session data is configurable and must respond to +encode+ and +decode+.
# Both methods must take a string and return a string.
#
# When the secret key is set, cookie data is checked for data integrity.
# The old_secret key is also accepted and allows graceful secret rotation.
# A legacy_hmac_secret is also accepted and is used to upgrade existing
# sessions to the new encryption scheme.
#
# There is also a legacy_hmac_coder option which can be set if a non-default
# coder was used for legacy session cookies.
#
# Example:
#
# use Rack::Protection::EncryptedCookie,
# :key => 'rack.session',
# :domain => 'foo.com',
# :path => '/',
# :expire_after => 2592000,
# :secret => 'change_me',
# :old_secret => 'old_secret'
#
# All parameters are optional.
#
# Example using legacy HMAC options
#
# Rack::Protection:EncryptedCookie.new(application, {
# # The secret used for legacy HMAC cookies
# legacy_hmac_secret: 'legacy secret',
# # legacy_hmac_coder will default to Rack::Protection::EncryptedCookie::Base64::Marshal
# legacy_hmac_coder: Rack::Protection::EncryptedCookie::Identity.new,
# # legacy_hmac will default to OpenSSL::Digest::SHA1
# legacy_hmac: OpenSSL::Digest::SHA256
# })
#
# Example of a cookie with no encoding:
#
# Rack::Protection::EncryptedCookie.new(application, {
# :coder => Rack::Protection::EncryptedCookie::Identity.new
# })
#
# Example of a cookie with custom encoding:
#
# Rack::Protection::EncryptedCookie.new(application, {
# :coder => Class.new {
# def encode(str); str.reverse; end
# def decode(str); str.reverse; end
# }.new
# })
#
class EncryptedCookie < Rack::Session::Abstract::Persisted
# Encode session cookies as Base64
class Base64
def encode(str)
[str].pack('m0')
end

def decode(str)
str.unpack('m').first
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

def initialize(app, options={})
# Assume keys are hex strings and convert them to raw byte strings for
# actual key material
@secrets = options.values_at(:secret, :old_secret).compact.map { |secret|
[secret].pack('H*')
}

warn <<-MSG unless secure?(options)
SECURITY WARNING: No secret option provided to Rack::Protection::EncryptedCookie.
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

warn <<-MSG if @secrets.first && @secrets.first.length < 32
SECURITY WARNING: Your secret is not long enough. It must be at least
32 bytes long and securely random. To generate such a key for use
you can run the following command:

ruby -rsecurerandom -e 'p SecureRandom.hex(32)'

Called from: #{caller[0]}.
MSG

if options.has_key?(:legacy_hmac_secret)
@legacy_hmac = options.fetch(:legacy_hmac, OpenSSL::Digest::SHA1)

# Multiply the :digest_length: by 2 because this value is the length of
# the digest in bytes but session digest strings are encoded as hex
# strings
@legacy_hmac_length = @legacy_hmac.new.digest_length * 2
@legacy_hmac_secret = options[:legacy_hmac_secret]
@legacy_hmac_coder = (options[:legacy_hmac_coder] ||= Base64::Marshal.new)
else
@legacy_hmac = false
end

# If encryption is used we can just use a default Marshal encoder
# without Base64 encoding the results.
#
# If no encryption is used, rely on the previous default (Base64::Marshal)
@coder = (options[:coder] ||= (@secrets.any? ? Marshal.new : 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|
session_data = cookie_data = request.cookies[@key]

# Try to decrypt with the first secret, if that returns nil, try
# with old_secret
unless @secrets.empty?
session_data = Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets.first)
session_data ||= Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets[1]) if @secrets.size > 1
end

# If session_data is still nil, are there is a legacy HMAC
# configured, try verify and parse the cookie that way
if !session_data && @legacy_hmac
digest = cookie_data.slice!(-@legacy_hmac_length..-1)
cookie_data.slice!(-2..-1) # remove double dash
session_data = cookie_data if digest_match?(cookie_data, digest)

# Decode using legacy HMAC decoder
request.set_header(k, @legacy_hmac_coder.decode(session_data) || {})
else
request.set_header(k, coder.decode(session_data) || {})
end
end
end

def persistent_session_id!(data, sid=nil)
data ||= {}
data["session_id"] ||= sid || generate_sid
data
end

def write_session(req, session_id, session, options)
session = session.merge("session_id" => session_id)
session_data = coder.encode(session)

unless @secrets.empty?
session_data = Rack::Protection::Encryptor.encrypt_message(session_data, @secrets.first)
end

if session_data.size > (4096 - @key.size)
req.get_header(RACK_ERRORS).puts("Warning! Rack::Protection::EncryptedCookie data size exceeds 4K.")
nil
else
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 digest_match?(data, digest)
return false unless data && digest

Rack::Utils.secure_compare(digest, generate_hmac(data))
end

def generate_hmac(data)
OpenSSL::HMAC.hexdigest(@legacy_hmac.new, @legacy_hmac_secret, data)
end

def secure?(options)
@secrets.size >= 1 ||
(options[:coder] && options[:let_coder_handle_secure_encoding])
end
end
end
end
61 changes: 61 additions & 0 deletions rack-protection/lib/rack/protection/encryptor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
require 'openssl'

module Rack
module Protection
module Encryptor
CIPHER = 'aes-256-gcm'.freeze
DELIMITER = '--'.freeze

def self.base64_encode(str)
[str].pack('m0')
end

def self.base64_decode(str)
str.unpack('m0').first
end

def self.encrypt_message(data, secret, auth_data = '')
raise ArgumentError, "data cannot be nil" if data.nil?

cipher = OpenSSL::Cipher.new(CIPHER)
cipher.encrypt
cipher.key = secret[0, cipher.key_len]

# Rely on OpenSSL for the initialization vector
iv = cipher.random_iv

# This must be set to properly use AES GCM for the OpenSSL module
cipher.auth_data = auth_data

cipher_text = cipher.update(data)
cipher_text << cipher.final

"#{base64_encode cipher_text}#{DELIMITER}#{base64_encode iv}#{DELIMITER}#{base64_encode cipher.auth_tag}"
end

def self.decrypt_message(data, secret)
return unless data

cipher = OpenSSL::Cipher.new(CIPHER)
cipher_text, iv, auth_tag = data.split(DELIMITER, 3).map! { |v| base64_decode(v) }

# This check is from ActiveSupport::MessageEncryptor
# see: https://github.com/ruby/openssl/issues/63
return if auth_tag.nil? || auth_tag.bytes.length != 16

cipher.decrypt
cipher.key = secret[0, cipher.key_len]
cipher.iv = iv
cipher.auth_tag = auth_tag
cipher.auth_data = ''

decrypted_data = cipher.update(cipher_text)
decrypted_data << cipher.final
decrypted_data

rescue OpenSSL::Cipher::CipherError, TypeError, ArgumentError
nil
end
end
end
end
11 changes: 5 additions & 6 deletions rack-protection/lib/rack/protection/session_hijacking.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,22 @@ module Protection
# spoofed, too, this will not prevent determined hijacking attempts.
class SessionHijacking < Base
default_reaction :drop_session
default_options :tracking_key => :tracking, :encrypt_tracking => true,
default_options :tracking_key => :tracking,
:track => %w[HTTP_USER_AGENT]

def accepts?(env)
session = session env
key = options[:tracking_key]
if session.include? key
session[key].all? { |k,v| v == encrypt(env[k]) }
session[key].all? { |k,v| v == encode(env[k]) }
else
session[key] = {}
options[:track].each { |k| session[key][k] = encrypt(env[k]) }
options[:track].each { |k| session[key][k] = encode(env[k]) }
end
end

def encrypt(value)
value = value.to_s.downcase
options[:encrypt_tracking] ? super(value) : value
def encode(value)
value.to_s.downcase
end
end
end
Expand Down