Session-only cookie corruption in Ruby web apps

Rack and Rails have a cookie monster.

Browsers place limits on the number and size of cookies present for a domain or in a response. If you exceed these limits, Bad Things can happen. Rack and Rails try to prevent this in the obvious cases, but this post describes what they get wrong in their current implementations. We’ll also review the potential impact of—and how you can mitigate—this type of issue in your Ruby web apps.

This information is most relevant for web apps that transmit session cookies that contain the encoded contents of the entire session hash—not just the session ID. In other words, a cookie-only session. For example, Rack::Session::Cookie with Marshal or JSON, Rails’ default ActionDispatch::Session::CookieStore, or an implementation of JWT (JSON Web Tokens) that uses a cookie (instead of a dedicated response header) as its transport. The security risks are greatest when cookie-only sessions meet the cookie-truncation behavior of older browsers (and can be compounded when the sessions contain arbitrarily-large data, such as flash messages).

TL;DR: If your Ruby web apps use cookie-only sessions, consider adding Rack::Protection::MaximumCookie to their middleware stacks.

Contents

The Limits

The exact limits in question vary from browser to browser. There are limits on the number and size of cookies, the latter of which will be the focus of this post. Evergreen browsers (i.e. Chrome, Edge, Firefox, and Safari) have a fairly forgiving per-cookie size limit (and sane behavior when the limit is exceeded). Older browers, by contrast, have per-domain size limits—with or without per-cookie overhead—and may count either characters or bytes (and truncate cookies when the limit is exceeded).

Evergreen Browsers

Testing on the latest desktop evergreen browsers reveals the following limits:

  • Chrome 64: 4,095 bytes per cookie and 180 cookies per domain
  • Edge 38: 5,117 bytes per cookie and 10,234 bytes and 50 cookies per domain (thanks Ian!)
  • Firefox 56: 4,096 bytes per cookie and 150 cookies per domain
  • Safari 11: 4,096 bytes per cookie

When limits are exceeded, these browsers simply start dropping cookies. This could lead to situations where e.g. users think they are logging in but inexplicable get bounced back to the login screen. Without any protective measures, you would have no way of knowing this was happening (aside from unhappy user feedback).

The main point I want to call out here is that the size limit applies to the cookie key and value as well as any directives. For example, if a cookie key=value string is 4,090 bytes of UTF-8, simply adding ; path=/ to the Set-Cookie header would be enough to push it over the limit and cause it to be silently dropped on all of these browers. In practice, these directives can consume quite a bit of space, e.g. ; path=/shop; domain=example.org; expires=Sat, 04 Nov 2017 00:02:20 -0000; Secure; HttpOnly; SameSite=Strict (108 bytes).

Older Browers

N.B.: For brevity’s sake, this may include modern, non-evergreen browsers such as IE11, and mobile browsers such as Safari for iOS.

The limits for older browsers are all over the map. This reference is fairly out of date, but it paints a picture of variability across the different browser products and versions. The two main things to note are:

  1. The size limit is typically enforced per-domain, not per-cookie.
  2. A per-cookie overhead is incurred in some browsers.

This is perhaps best explained with an example. Consider a browser that has a 4,096-byte cookie size limit per domain with three (3) bytes of overhead per cookie. For any given domain, e.g. example.org, you may have one 4,093-byte cookies, or one 4,000-byte cookie and one 90-byte cookie, or four 1,000-byte cookies and one 81-byte cookie, etc. The sum of the cookie sizes plus the per-cookie overhead must not exceed the limit.

The other critical difference when compared with evergreen browsers is that older browsers may silently truncate cookies when the limit is exceeded. This is a serious issue for two reasons:

  1. The data (or digest) may be corrupted, leading to an invalid session.
  2. Directives such as Secure, HttpOnly, and SameSite may be stripped from the cookie.

If the Secure directive is stripped, the browser may inadvertently send the cookie over an insecure connection, allowing it to be stolen and used by a malicious third-party. If the HttpOnly directive is stripped, the cookie becomes vulnerable to XSS (cross-site scripting) attacks (i.e. it becomes accessible from JavaScript). If the SameSite directive is stripped, the cookie becomes vulnerability to CSRF (cross-site request forgery) attacks*.

* – Granted, the intersection of browsers that implement SameSite and truncate oversized cookies is probably very small, if not the null set.

Rack and Rails

Clearly, cookie size limits are nothing new, and the maintainers of Rack and Rails have been aware of it for quite some time. Rack::Session::Cookie and ActionDispatch::Session::CookieStore attempt to protect you from silent errors and vulnerabilities by checking the size of the key=value string againt a limit of 4,096 bytes or characters. However, this is an invalid strategy for the following reasons (all of which should hopefully be clear if you’ve read up to this point):

  1. It does not include the directives.
  2. It does not account for any per-cookie overhead.
  3. It does not aggregate the cookie sizes by domain.

Additionally, a key=value string may pass an initialize size check, but some encoding artifacts caused by the escape method in Rack::Utils may grow the string such that it exceeds the limit. You can see examples of this in the README of Rack::Session::SmartCookie.

JWT

JWT implementations that use a cookie transport must perform their own limit checks.

The Solution

Until the underlying issues are fixed in Rack and Rails, the best way to protect new and existing web apps that use cookies is to add a piece of middleware the implements the correct cookie limit checks. It should raise errors when the limits are exceeded so that corrective application-level actions can be taken.

I’ve published Rack::Protection::MaximumCookie to accomodate this need for the short- to mid-term. It addresses the JWT and other use cases as well. It’s a work in progress, but its default configuration should be appropriate for most web apps. It’s configurable to target only the evergreens, or configurable for increasing levels of conservativeness. Please read the caveats carefully; feedback, suggestions, and pull requests are welcome.


  • This issue was first reported to rack-core on 10/28 and the Rails security team (via HackerOne) on 11/3. It was (apparently) not deemed a significant security risk. The gem and this blog post were held until 11/20 to give these teams an adequate amount of time to respond.

6 thoughts on “Session-only cookie corruption in Ruby web apps

  1. My results from Edge (v 38.14393.1066.0) EdgeHTML (v14.14393) on Windows 10 Enterprise 1607:

    11:54:36.658: Guessing Max Cookie Count Per Domain: 50
    11:54:36.659: Guessing Max Cookie Size Per Cookie: 5117 characters
    11:54:36.660: Guessing Max Cookie Size Per Domain: Between 10234 and 15350 characters

    11:55:36.507: Guessing Max Cookie Size Per Domain: 10234 characters

Leave a Reply

Your email address will not be published. Required fields are marked *