Luke Marshall

RubyGems Cache Vulnerability Exposed API Tokens

RubyGems Cache Vulnerability Exposed API Tokens

Luke Marshall

TL;DR

Any unauthenticated user could send a GET request to the RubyGems API and be served cached responses that included other users’ valid API tokens.

Retrieving RubyGems API Tokens

The RubyGems API was caching all GET responses to its API endpoints. This included the endpoints that returned API keys, such as:

  • api_key.json

  • api_key

  • api_key.yaml

The prerequisite was that the request had to be sent with the Accept-Encoding header set to either gzip or gzip, deflate, br.

This wasn’t much of a barrier, since most web browsers and HTTP clients set Accept-Encoding to gzip by default. RubyGems’ own CLI package, gem, set this encoding by default and also made a GET request to /api/v1/api_key and /api/v1/me/profile.yaml during the signin process, which meant it was vulnerable.

Here’s how the attack worked using the gem CLI on version 3.2.0 and below. User A and User B were on separate networks, but in the same geographical region (within a few hundred miles).

User AvictimFastly edgecacherubygems.orgoriginUser Battackergem signin — GET /api/v1/api_key200 — responds with API keycached · keyed on URLGET /api/v1/api_key (same URL)200 (cache HIT) — victim's tokenvalid token → push / yank gems
01User A signs in
The gem CLI sends an authenticated GET for the API key.

User A (victim)

  1. Ran gem signin

In the background, the gem CLI authenticated with basic auth (username:password) and made a GET request to /api/v1/api_key, then stored the returned API key in ~/.gem/credentials.

User A runs gem signin; the gem CLI fetches and stores an API token in ~/.gem/credentials

User A signed in with the gem CLI, which fetched and stored an API token in ~/.gem/credentials.

If the authenticating user didn’t have an API key available, the gem CLI would create an API token automatically, with the following scopes:

RubyGems API keys page showing the broad default scopes granted to a gem-created token

With no existing key, the gem CLI silently created a new token with broad default scopes.

User B (attacker)

  1. Ran this command:

curl --compressed "https://rubygems.org/api/v1/api_key"

User B runs curl --compressed and receives the victim's cached API token

User B requested the same URL and was served User A’s cached token.

But this didn’t have to be a malicious request — it could happen by accident. If two users authenticated via the gem CLI at the same time, neither had credentials in ~/.gem/credentials, and they shared a POP region, it was likely they would end up sharing an API key.

We tested this and it worked on the first try: using two accounts, we authenticated at the same time while on two different VPNs in the same region.

The result: both accounts shared one API token — with some pretty permissive scopes, we might add.

Two accounts signing in at the same time from the same region share one API token

Two users signing in at once from the same region could be handed the same token.

Where the vulnerability existed

The root cause was a shared cache that keyed on the request URL but not on the Authorization header. Fastly’s edge cached authenticated responses and then served them to anyone requesting the same URL, treating a private, per-user response as if it were a public, cacheable resource.

Because Fastly POPs are metro-region based, an attacker only needed to be reasonably close to the victim, or retry from multiple vantage points until they landed on the same edge node that held the cached response.

This only happened for requests sent with the Accept-Encoding: gzip (or gzip, deflate, br) header, which is exactly what most browsers and HTTP clients send by default.

HTTP 200 with API key, x-cache MISS

An authenticated request returned the API key and the edge cached the response (x-cache: MISS on the first fill, keyed on the URL, not on the auth header).

HTTP 401 Access denied

The same endpoint without credentials returned 401, the origin correctly refused whenever the response wasn’t served from cache.

Recreating it locally

We stood up a tiny lab with two pieces:

  • An origin (a small app) that behaves correctly: it requires a username and password to return the token, sends Cache-Control: no-cache, and returns 401 Unauthorized if you don’t log in.

  • Varnish sitting in front of it, configured with the bug.

The entire vulnerability lives in a few lines of Varnish config. In plain English it says: “If someone asks for a compressed response, cache it, even if they were logged in, and even if the origin said not to.”

sub vcl_recv {
    if (req.url ~ "^/api/v1/api_key") {
        if (req.method == "GET" && req.http.Accept-Encoding ~ "gzip") {
            set req.http.X-AE-Cache = "1";
            return (hash);
        }
        return (pass);
    }
}

sub vcl_backend_response {
    if (bereq.http.X-AE-Cache == "1" && beresp.status == 200) {
        set beresp.ttl = 120s;
        set beresp.do_gzip = true;
        return (deliver)

Two things are happening that make this bug possible:

  • Varnish’s cache key is just the URL, it does not include who you are, so the logged-in user and the anonymous attacker look up the exact same cached item.

  • The config throws away the origin’s no-cache instruction and stores the response anyway.

Proving it

We fired three curl requests at our local Varnish:

1. The victim (logged in, compressed) fills the cache, this is essentially what the gemCLI does.

curl -si -u username:password --compressed \
  "http://localhost:8080/api/v1/api_key?cb=284"

HTTP/1.1 200 OK
Cache-Control: no-cache
X-Request-Id: bd6c64ae-c400-4b9d-99ac-13c0ce2eb799
X-Runtime: 0

A normal, correct, authenticated response, the token was returned and X-Cache: MISS.

2. The attacker sends a request with no basic auth but with the --compressed flag, the cached response is returned.

curl -si --compressed \
  "http://localhost:8080/api/v1/api_key?cb=284"

HTTP/1.1 200 OK
X-Request-Id: bd6c64ae-c400-4b9d-99ac-13c0ce2eb799 
X-Runtime: 0.363824                                
X-Cache: HIT
X-Cache-Hits: 1

3. If we omit the --compressed flag, the application behaves as expected, no cached response is returned.

curl -si "http://localhost:8080/api/v1/api_key?cb=284"

HTTP/1.1 401

Impact

A RubyGems API token is a supply-chain credential. Depending on the account, it could push new gem versions, yank releases, and manage owned packages. If an affected account maintained a widely used gem, the cache bug became a direct path from an unauthenticated HTTP request to package compromise, on top of full API-level account takeover.



RubyGems hosts over 300,000 packages that have been downloaded a total of 250m+ times.

Disclosure

TODO: Fill in discovery, report submission, vendor acknowledgement, mitigation, verification, and publication dates once the disclosure timeline is finalized.

infra