Luke Marshall

Securing the Supply Chain: Cache Vulnerability in RubyGems

Securing the Supply Chain: Cache Vulnerability in RubyGems

Luke Marshall

tl;dr A cache configuration flaw in RubyGems.org allowed gzip-compressed responses from authenticated GET /api/v1/api_key requests to be stored at a Fastly edge. For up to one hour, another caller routed through the same edge could receive the cached response, and its freshly minted legacy API key without authentication.

Retrieving RubyGems API Tokens

When a request used gzip compression, RubyGems.org’s CDN could cache the authenticated response and serve it to another user.


This included endpoints that returned valid API keys, such as:

  • rubygems.org/api/v1/api_key.json

  • rubygems.org/api/v1/api_key

  • rubygems.org/api/v1/api_key.yaml

https://guides.rubygems.org/rubygems-org-api/

https://guides.rubygems.org/rubygems-org-api/

A request using Basic Auth and --compressed simulated Accept-Encoding: gzip and could populate the shared cache.

A later request without Basic Auth, but with the same Accept-Encoding behavior, could receive the same RubyGems token from cache.

Most web browsers and HTTP clients set Accept-Encoding to gzip by default, including RubyGems own gem CLI. With this in mind, we looked at which requests the gem CLI was sending to the RubyGems API.

RubyGems CLI versions < 3.2.0 made GET requests to rubygems.org/api/v1/api_key. RubyGems versions >= 3.2.0 uses the scoped API-key POST /api/v1/api_key signin flow (including push_rubygem scope) which was added in PR #3840 via commit 35b3bdb.

This meant that anyone signing in through an affected gem CLI version could have their API key cached on the local CDN point of presence (POP). That cached response could then be served back to another user on the same POP, completely unauthenticated.

Here’s how it worked using the gem CLI:

User A - the victim

  1. Runs gem signin while using one of the affected RubyGems CLI versions.

  2. The CLI authenticates using HTTP Basic authentication and requests GET /api/v1/api_key.

  3. RubyGems.org creates a legacy API key and returns it in the response.

  4. Fastly caches the gzip response at the edge.

The returned legacy API token is stored locally in ~/.gem/credentials.

The stored API token is a freshly minted legacy token with permissions that include:

  • Index rubygems

  • Yank rubygem

  • Add owner

  • Update owner

  • Remove owner

  • Access webhooks

  • Configure trusted publishers

User B - the attacker

  1. Is routed through the same Fastly edge while the response remains cached.

  2. Requests the same cache variant:

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

  1. Receives User A's cached legacy API key without authenticating.

The --compressed flag sets Accept-Encoding behaviour that includes gzip. The first request missed the POP but the next request hit.


Accidental Exposure

Exploitation did not necessarily need to be intentional. If two users signed in with affected clients through the same Fastly edge during the cache window, the second user could receive the first user’s key.

Account 1

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

Account 2

In our testing, two completely different accounts using different emails and passwords signed in through the gem CLI but were served the same legacy RubyGems API token.


Technical Deep Dive


The Endpoint

The reason this vulnerability is impactful is the rubygems.org/api/v1/api_key endpoint. It authenticates with HTTP Basic Auth and, on every successful authenticated GET, mints a brand-new legacy API key and returns it in the response body. Both the .json and .yaml variants behaved the same way. The affected gem CLI versions made the same class of request to the /api_key endpoint.

Condition 1 - response storable in a shared cache

On the gzip path, Rack::Deflater replaced the response body with a gzip stream. Rack::ETag could not inspect that body and left the response with only Cache-Control: no-cache, without private, no-store, or Set-Cookie. Although no-cache permits storage only with revalidation, RubyGems.org’s deployed Fastly configuration stored and replayed the response without contacting the origin.

A request that did not accept gzip received Cache-Control: private, must-revalidate and did not reproduce the vulnerability.

Condition 2 - the cache key ignored identity

For a given endpoint URL, Fastly separated representations by encoding through Vary: Accept-Encoding. It did not separate them by authentication identity because Authorization was absent from Vary. Consequently, callers reaching the same edge and encoding-specific cache variant could receive the same response regardless of their credentials.

Time To Live

We confirmed with the RubyGems team that cached entries carried a TTL of one hour. We could not confirm each entry’s real lifetime under load: whether a cached token remained in cache for the full hour, or whether cache pressure evicted it earlier.

The Fix

RubyGems.org took three immediate remediation steps:

  • Purged the affected objects from Fastly.

  • Deployed application-level cache protections.

  • Revoked all legacy API keys.

RubyGems shipped this fix in the Rails/Rack layer, adding a helper to the API base controller: https://github.com/rubygems/rubygems.org/commit/d3d11c0ecfb3827778e1e1499372e351edaa83cc

def deny_shared_cache
  response.headers["Cache-Control"]     = "private, no-store"
  response.headers["Surrogate-Control"] = "max-age=0"
  response.headers["Vary"] = [response.headers["Vary"], "Authorization"].compact_blank.join(", ")
end

https://github.com/rubygems/rubygems.org/commit/d3d11c0ecfb3827778e1e1499372e351edaa83cc#diff99fc98368f0ff5548762b254189af2de831a1c6689e45225845b66a73a12fb93R98-R103

The helper is applied as a before_action across every authenticated endpoint that returns per-user data.

The patch also prevents the api_key action from minting a key on a HEAD request.

if request.head?
  head :ok
else
  key = generate_unique_rubygems_key
  api_key = user.api_keys.build(legacy_key_defaults.merge(hashed_key: hashed_key(key)))
  save_and_respond(api_key, key)
end

https://github.com/rubygems/rubygems.org/commit/d3d11c0ecfb3827778e1e1499372e351edaa83cc#diff-6c57bb4e0851e0be0b239373290b1d7e5a84f82468574e4564be03c38bcad295R13-R17

We confirmed with RubyGems that the fix works and that api_key responses are no longer cached.

Impact

RubyGems.org is the central package registry for the Ruby ecosystem, making the potential compromise of publishing credentials a significant supply-chain risk.

A leaked legacy key could allow an attacker to publish a new gem version or platform variant, yank versions, change ownership, manage webhooks, or configure a trusted publisher. Adding an owner or trusted publisher could create persistent access that survived revocation of the original key.

https://rubygems.org/


The RubyGems security team outlined the exact limits of the impact:

  • An attacker could publish a new version or platform variant, yank versions, alter ownership, configure persistent trusted publishers, or manipulate webhooks.

  • Adding an owner or trusted publisher could persist after key revocation.

  • Existing gem releases could not be overwritten.

  • A previously used name/version/platform tuple could not be repushed.

  • Installation of gems was never affected by the cache bug itself.

We did not access any other users' packages or accounts during our research. We validated the issue using our own test accounts across different regions, confirmed the vulnerability, and reported it to the RubyGems security team.

Conclusion

By sending Accept-Encoding: gzip, an authenticated request to the RubyGems API could populate a shared CDN cache with a response containing a user’s valid RubyGems API token. That cached response could then be served to an unauthenticated user routed through the same CDN POP.

No supported versions of the gem CLI used the vulnerable code path, this limited the exposure the bug had in a real world scenario. The last GET-pathway RubyGems release was 3.1.6 (2022-04-11); it has no standalone end of life (EOL) but inherits end-of-support from its bundled Ruby 2.7 line, EOL 2023-03-31.

Disclosure Timeline

10 December 2020 (AEDT): RubyGems v3.2.0 released; the modern gem signin stops calling the GET endpoint, though the endpoint remains for older clients.

6 July 2026 (AEDT): reported to RubyGems.org by Luke Marshall (Truffle Security)

9 July 2026 (AEDT): root-cause fix deployed (commit d3d11c0), Fastly purged

23 July 2026 (AEDT): Legacy API keys revoked, affected users notified & public disclosure published

RubyGems Security Advisory

RubyGems has published a security advisory, GHSA-9j48-x3c3-mrp2, and fully remediated the vulnerability. You can read RubyGems’ detailed write-up here: https://blog.rubygems.org/2026/07/22/security-advisory-legacy-api-key-leak.html

infra