Skip to content

rack-session v2.1.2 produces 'invalid message' #60

Description

@andreavocado

Root Cause

rack-session 2.1.2 introduced a new V2 encryptor (AES-256-GCM) that uses Base64.strict_encode64 for cookies — standard base64 which produces +, /, and = characters.

The problem: Rack's parse_cookies_header (in rack/utils.rb) applies URI.decode_www_form_component to every cookie value, which converts + → space (a form-encoding convention that has no place in cookie parsing).

The flow:

  1. Server creates V2-encrypted session cookie with + in its base64 value
  2. Browser sends it back unchanged (browsers don't re-encode +)
  3. Rack::Utils.parse_cookies_header decodes + → space, corrupting the value
  4. Base64.strict_decode64 fails or produces garbage → guess_decryptor raises InvalidMessage, 'invalid message'
  5. Logged as: Session cookie encryptor error: invalid message

The test above shows 90% of V2 cookies will be affected. V1 cookies are immune because they use Base64.urlsafe_encode64 (- and _ instead of + and /), which URI.decode_www_form_component leaves untouched.

In 2.1.1, the single encryptor always used V1 (URL-safe base64), so this was never an issue.

✅ test

Verify Rack corrupts V2 standard base64 cookies

require 'uri'
require 'base64'
require 'openssl'

puts '=== Does Rack corrupt V2 cookies with + in base64? ==='
puts

# Generate several random payloads to see how often + appears
plus_count = 0
100.times do
  sample = OpenSSL::Random.random_bytes(80)
  v2_encoded = Base64.strict_encode64(sample)
  after_rack = URI.decode_www_form_component(v2_encoded)
  plus_count += 1 if after_rack != v2_encoded
end
puts \"Out of 100 V2 cookies, #{plus_count} would be corrupted by Rack's URI unescape (+ -> space)\"

puts
puts '=== V1 (urlsafe_encode64) - safe from Rack unescape? ==='
plus_count_v1 = 0
100.times do
  sample = OpenSSL::Random.random_bytes(80)
  v1_encoded = Base64.urlsafe_encode64(sample)
  after_rack = URI.decode_www_form_component(v1_encoded)
  plus_count_v1 += 1 if after_rack != v1_encoded
end
puts \"Out of 100 V1 cookies, #{plus_count_v1} would be corrupted by Rack's URI unescape\"

Activity

ioquatix commented on Apr 21, 2026

@ioquatix
Member

Are you able to submit a PR to fix the issue and add tests?

andreavocado commented on Apr 22, 2026

@andreavocado
ContributorAuthor

@ioquatix Here we go, please have a look: #61

ioquatix commented on Apr 24, 2026

@ioquatix
Member

Thanks for the fix.

I'm okay to merge it, since it fixes the immediate issue.

But do you think we should also fix Rack?

andreavocado commented on Apr 27, 2026

@andreavocado
ContributorAuthor

I asked copilot to answer this question:

Yes, this is absolutely an issue in rack itself.

The bug is on line 334:

cookies[key] = (unescape(value) rescue value) unless cookies.key?(key)

unescape calls URI.decode_www_form_component, which converts + → space. This is form-encoding semantics incorrectly applied to cookie parsing.

RFC 6265 defines cookie values as opaque strings — + should be treated as a literal +, never decoded. The rescue value fallback only catches exceptions (like malformed %xx sequences), not the silent + → space corruption.

So the full chain you described applies here too:

  1. Any cookie value containing + (e.g. standard Base64 from rack-session V2) arrives from the browser unchanged
  2. parse_cookies_header silently replaces + with space
  3. The value is corrupted before it even reaches rack-session

The fix should be to not call unescape on cookie values at all, or at minimum use CGI.unescape-style percent-decoding only (which doesn't treat + specially):

Instead of unescape (which uses decode_www_form_component):
cookies[key] = (URI.decode_uri_component(value) rescue value) unless cookies.key?(key)

Or more simply, only decode %xx sequences without touching +:

cookies[key] = value.gsub(/%([0-9A-Fa-f]{2})/) { $1.hex.chr } rescue value

Therefore I also created a fix for rack: https://github.com/rack/rack/pull/2454/changes

@ioquatix What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions