Skip to content
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
18 changes: 8 additions & 10 deletions lib/rack/session/encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ class V2
#
# Cryptography and Output Format:
#
# strict_encode64(version + salt + IV + authentication tag + ciphertext)
# urlsafe_encode64(version + salt + IV + authentication tag + ciphertext)
#
# Where:
# * version - 1 byte with value 0x02
Expand All @@ -223,13 +223,11 @@ class V2
#
# Considerations about V2:
#
# 1) It uses non URL-safe Base64 encoding as it's faster than its
# URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
# roughly equivalent to
#
# Base64.strict_encode64(data).tr("-_", "+/")
#
# - and cookie values don't need to be URL-safe.
# 1) It uses URL-safe Base64 encoding (RFC 4648) to ensure cookie values
# are not corrupted by Rack's cookie parser, which applies
# URI.decode_www_form_component to cookie values and converts '+' to
# space. Standard Base64 (strict_encode64) can produce '+' characters,
# which would corrupt the cookie value and cause decryption to fail.
def initialize(secret, opts = {})
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)

Expand All @@ -255,7 +253,7 @@ def initialize(secret, opts = {})
end

def decrypt(base64_data)
data = Base64.strict_decode64(base64_data)
data = Base64.urlsafe_decode64(base64_data)
if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :)
raise InvalidMessage, 'invalid message'
end
Expand Down Expand Up @@ -305,7 +303,7 @@ def encrypt(message)
data << auth_tag_from(cipher)
data << encrypted_data

Base64.strict_encode64(data)
Base64.urlsafe_encode64(data)
end

private
Expand Down
4 changes: 2 additions & 2 deletions test/spec_session_cookie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ def decode(str); @calls << :decode; JSON.parse(str); end
response.body.must_equal ({"counter"=>2}.to_s)

encoded_cookie = response["Set-Cookie"].split('=', 2).last.split(';').first
decoded_cookie = Base64.strict_decode64(Rack::Utils.unescape(encoded_cookie))
decoded_cookie = Base64.urlsafe_decode64(Rack::Utils.unescape(encoded_cookie))

tampered_cookie = "rack.session=#{Base64.strict_encode64(decoded_cookie.tap { |m|
tampered_cookie = "rack.session=#{Base64.urlsafe_encode64(decoded_cookie.tap { |m|
m[m.size - 1] = (m[m.size - 1].unpack('C')[0] ^ 1).chr
})}"

Expand Down
32 changes: 26 additions & 6 deletions test/spec_session_encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def encryptor_class
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
message = encryptor.encrypt({ 'foo' => 'bar' * 4 })

decoded_message = Base64.strict_decode64(message)
decoded_message = Base64.urlsafe_decode64(message)

# slice 1 byte for version, 32 bytes for cipher_secret, 12 bytes for IV,
# 16 bytes for the auth tag from the start of the string
Expand All @@ -252,9 +252,9 @@ def encryptor_class
encryptor = new_encryptor(@secret, purpose: 'testing')
message = encryptor.encrypt({ 'foo' => 'bar' })

decoded_message = Base64.strict_decode64(message)
decoded_message = Base64.urlsafe_decode64(message)
decoded_message[0] = "\1"
reencoded_message = Base64.strict_encode64(decoded_message)
reencoded_message = Base64.urlsafe_encode64(decoded_message)

-> { encryptor.decrypt(reencoded_message) }.must_raise Rack::Session::Encryptor::InvalidMessage
end
Expand All @@ -267,7 +267,7 @@ def encryptor_class
encryptor = new_encryptor(@secret)

message = encryptor.encrypt({ 'foo' => 'bar' })
raw_message = Base64.strict_decode64(message)
raw_message = Base64.urlsafe_decode64(message)

version = raw_message.slice!(0, 1)
salt = raw_message.slice!(0, 32)
Expand All @@ -287,7 +287,7 @@ def encryptor_class
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
message = encryptor.encrypt({ 'foo' => 'bar' })

message_key = Base64.strict_decode64(message).slice(1, 32)
message_key = Base64.urlsafe_decode64(message).slice(1, 32)

callable = proc do |cipher, key|
key.wont_equal @secret
Expand Down Expand Up @@ -334,7 +334,7 @@ def encryptor_class
encryptor = Rack::Session::Encryptor.new(@secret, { mode: :not_v1 })

encrypted_message = encryptor.encrypt({ 'foo' => 'bar' })
version = Base64.strict_decode64(encrypted_message)[0]
version = Base64.urlsafe_decode64(encrypted_message)[0]

version.must_equal "\2"
end
Expand Down Expand Up @@ -373,5 +373,25 @@ def encryptor_class
decrypted_message_v1.must_equal({ 'foo' => 'bar' })
decrypted_message_v2.must_equal({ 'foo' => 'bar' })
end

# Rack's parse_cookies_header applies URI.decode_www_form_component to
# cookie values, which converts '+' to space. Standard Base64
# (strict_encode64) can produce '+' characters, which would corrupt the
# cookie before decryption. V2 must use URL-safe Base64 to avoid this.
it 'decrypts V2 messages that have passed through Rack cookie parsing' do
encryptor = Rack::Session::Encryptor.new(@secret, { mode: :v2 })
encrypted_message = encryptor.encrypt({ 'foo' => 'bar' })

# V2 output must only contain URL-safe Base64 characters; '+' and '/'
# are the characters that strict_encode64 produces but urlsafe_encode64
# does not, and which Rack's cookie parser would corrupt.
encrypted_message.must_match(/\A[A-Za-z0-9\-_=]+\z/)

# Simulate what Rack::Utils.parse_cookies_header does to cookie values
cookie_value_after_rack = URI.decode_www_form_component(encrypted_message)
cookie_value_after_rack.must_equal encrypted_message

encryptor.decrypt(cookie_value_after_rack).must_equal({ 'foo' => 'bar' })
end
end
end
Loading