diff --git a/lib/rack/session/encryptor.rb b/lib/rack/session/encryptor.rb index 851245b..acbe128 100644 --- a/lib/rack/session/encryptor.rb +++ b/lib/rack/session/encryptor.rb @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/test/spec_session_cookie.rb b/test/spec_session_cookie.rb index 0e5345a..1ae7bbd 100644 --- a/test/spec_session_cookie.rb +++ b/test/spec_session_cookie.rb @@ -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 })}" diff --git a/test/spec_session_encryptor.rb b/test/spec_session_encryptor.rb index eeb95fe..59c22bd 100644 --- a/test/spec_session_encryptor.rb +++ b/test/spec_session_encryptor.rb @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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