Skip to content

Commit 7acef42

Browse files
authored
Fix 'invalid message' for V2 encryption (#61)
Replace `strict_encode64` with `urlsafe_encode64` due to compatibility issues.
1 parent 63c287f commit 7acef42

3 files changed

Lines changed: 36 additions & 18 deletions

File tree

lib/rack/session/encryptor.rb

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ class V2
213213
#
214214
# Cryptography and Output Format:
215215
#
216-
# strict_encode64(version + salt + IV + authentication tag + ciphertext)
216+
# urlsafe_encode64(version + salt + IV + authentication tag + ciphertext)
217217
#
218218
# Where:
219219
# * version - 1 byte with value 0x02
@@ -223,13 +223,11 @@ class V2
223223
#
224224
# Considerations about V2:
225225
#
226-
# 1) It uses non URL-safe Base64 encoding as it's faster than its
227-
# URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
228-
# roughly equivalent to
229-
#
230-
# Base64.strict_encode64(data).tr("-_", "+/")
231-
#
232-
# - and cookie values don't need to be URL-safe.
226+
# 1) It uses URL-safe Base64 encoding (RFC 4648) to ensure cookie values
227+
# are not corrupted by Rack's cookie parser, which applies
228+
# URI.decode_www_form_component to cookie values and converts '+' to
229+
# space. Standard Base64 (strict_encode64) can produce '+' characters,
230+
# which would corrupt the cookie value and cause decryption to fail.
233231
def initialize(secret, opts = {})
234232
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
235233

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

257255
def decrypt(base64_data)
258-
data = Base64.strict_decode64(base64_data)
256+
data = Base64.urlsafe_decode64(base64_data)
259257
if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :)
260258
raise InvalidMessage, 'invalid message'
261259
end
@@ -305,7 +303,7 @@ def encrypt(message)
305303
data << auth_tag_from(cipher)
306304
data << encrypted_data
307305

308-
Base64.strict_encode64(data)
306+
Base64.urlsafe_encode64(data)
309307
end
310308

311309
private

test/spec_session_cookie.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,9 @@ def decode(str); @calls << :decode; JSON.parse(str); end
355355
response.body.must_equal ({"counter"=>2}.to_s)
356356

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

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

test/spec_session_encryptor.rb

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def encryptor_class
239239
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
240240
message = encryptor.encrypt({ 'foo' => 'bar' * 4 })
241241

242-
decoded_message = Base64.strict_decode64(message)
242+
decoded_message = Base64.urlsafe_decode64(message)
243243

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

255-
decoded_message = Base64.strict_decode64(message)
255+
decoded_message = Base64.urlsafe_decode64(message)
256256
decoded_message[0] = "\1"
257-
reencoded_message = Base64.strict_encode64(decoded_message)
257+
reencoded_message = Base64.urlsafe_encode64(decoded_message)
258258

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

269269
message = encryptor.encrypt({ 'foo' => 'bar' })
270-
raw_message = Base64.strict_decode64(message)
270+
raw_message = Base64.urlsafe_decode64(message)
271271

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

290-
message_key = Base64.strict_decode64(message).slice(1, 32)
290+
message_key = Base64.urlsafe_decode64(message).slice(1, 32)
291291

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

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

339339
version.must_equal "\2"
340340
end
@@ -373,5 +373,25 @@ def encryptor_class
373373
decrypted_message_v1.must_equal({ 'foo' => 'bar' })
374374
decrypted_message_v2.must_equal({ 'foo' => 'bar' })
375375
end
376+
377+
# Rack's parse_cookies_header applies URI.decode_www_form_component to
378+
# cookie values, which converts '+' to space. Standard Base64
379+
# (strict_encode64) can produce '+' characters, which would corrupt the
380+
# cookie before decryption. V2 must use URL-safe Base64 to avoid this.
381+
it 'decrypts V2 messages that have passed through Rack cookie parsing' do
382+
encryptor = Rack::Session::Encryptor.new(@secret, { mode: :v2 })
383+
encrypted_message = encryptor.encrypt({ 'foo' => 'bar' })
384+
385+
# V2 output must only contain URL-safe Base64 characters; '+' and '/'
386+
# are the characters that strict_encode64 produces but urlsafe_encode64
387+
# does not, and which Rack's cookie parser would corrupt.
388+
encrypted_message.must_match(/\A[A-Za-z0-9\-_=]+\z/)
389+
390+
# Simulate what Rack::Utils.parse_cookies_header does to cookie values
391+
cookie_value_after_rack = URI.decode_www_form_component(encrypted_message)
392+
cookie_value_after_rack.must_equal encrypted_message
393+
394+
encryptor.decrypt(cookie_value_after_rack).must_equal({ 'foo' => 'bar' })
395+
end
376396
end
377397
end

0 commit comments

Comments
 (0)