flawopen.com/Incidents/rubygems-cdn-cache-api-key-leak-july-2026
RubyGems API key leak: GHSA-9j48-x3c3-mrp2
A gzip/CDN cache flaw exposed RubyGems API keys. See the Rails fix and recovery; AI attribution for the separate package campaign remains disputed.
Imagine a ticket desk that prints a new master key for each verified customer, then leaves a copy in a shared tray because a packaging machine changed how the envelope was labeled. The next person at that same desk could pick up the previous customer’s key without showing ID. The fix tells every cache to keep these private responses out of shared trays and to distinguish responses by authorization.
Core Concepts & Subsystem Terms
Legacy API key- A long-lived RubyGems credential with broad gem-management permissions. The advisory says it could publish a new gem version, yank versions, add owners, and change webhooks or trusted-publisher settings; it could not log in to the account or rewrite an existing release.
Shared cache / POP- A CDN point of presence stores a response for reuse by later requests. If an authenticated response containing a credential is shared without a private cache policy and an authorization-aware cache key, a later caller may receive another user’s response.
Compression middleware- RubyGems says Accept-Encoding: gzip triggered a Rack middleware interaction that left a cacheable response without the private directive and without Vary: Authorization. Ordinary requests without gzip did not show the same header behavior.
CWE-200 and CWE-524- The GitHub Security Advisory lists
CWE-200for exposure of sensitive information andCWE-524for use of a cache containing sensitive information. These describe the disclosure and the cache boundary; no CVE was assigned. RubyGems package-flood attribution- RubyGems confirmed abusive package publishing in May 2026 but said it could not determine whether AI agents authored or published those packages. Keep this campaign separate from the cache flaw: the sources do not establish that it caused key exposure.
Framework mappings
Incident Timeline
RubyGems 3.2.0 changed the standard gem signin flow to scoped-key creation, but the server kept the legacy GET endpoint available.
Truffle Security reported the cache issue to RubyGems.
RubyGems deployed the response-header fix and purged affected Fastly cache objects.
RubyGems published GHSA-9j48-x3c3-mrp2 and said it revoked all legacy keys. GHSA rates the issue 7.3 High; RubyGems reports an overall environmental score of 7.2 High.
RubyGems described the separate May package-publishing campaign and said it could not attribute it to AI agents.
Step-by-Step Attack Flow
A legacy endpoint mints a credential
An authenticated GET to /api/v1/api_key creates and returns a broad legacy API key. RubyGems says gem clients older than 3.2.0 used this route; the server kept the endpoint available after newer clients moved to scoped-key creation.
Gzip changes the response-header path
The client requests gzip. RubyGems attributes the cache behavior to the Rack::Deflater and Rack::ETag interaction: the compressed response ended up with a bare Cache-Control: no-cache rather than a private directive, and lacked Vary: Authorization.
The CDN reuses an authenticated response
Under the affected Fastly configuration, the response could be cached at an edge POP for up to an hour. The cache key did not separate callers by Authorization, so the edge could serve the previous response without contacting RubyGems to authenticate the next caller.
A different caller receives the key
Another request with the same cache variant routed through that POP could receive the earlier user’s key. RubyGems says this could happen accidentally between sign-ins; it also describes unauthenticated polling of a cached response. It found no evidence of malicious legacy-key use in the available logs, but those logs covered only a limited period.
Key scope bounds the consequences
A leaked legacy key could authorize new gem versions, yanks, owner changes, and webhook or trusted-publisher changes. Existing releases could not be rewritten. RubyGems says MFA set to protect both UI and API blocks key-only push, yank, and ownership actions.
The fix prevents shared reuse and retires exposed keys
The patch adds Cache-Control: private, no-store, Surrogate-Control: max-age=0, and Vary: Authorization; RubyGems purged Fastly objects, revoked legacy keys, and retired the GET signin endpoint. It recommends scoped keys, API MFA, and trusted publishing for CI.
Source Code: Flaw vs. Secure Implementation
The unpatched controller excerpt is reconstructed from the removed-side context of RubyGems’ public fix; the unsafe cache headers were produced by the Rack middleware interaction, not by this action setting a public cache policy. The hardened callback and header values match commit d3d11c0. private, no-store prevents shared storage, Surrogate-Control disables surrogate caching, and Vary: Authorization partitions any cache that still stores a response. This example is source analysis, not a request recipe or a standalone patch; use the complete vendor fix and purge pre-fix cache entries.
class Api::V1::ApiKeysController < Api::BaseController
include ApiKeyable
# The pre-fix controller had no deny_shared_cache callback.
def show
authenticate_or_request_with_http_basic do |username, password|
user = User.authenticate(username.strip, password)
check_mfa(user) do
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
end
end
end
class Api::V1::ApiKeysController < Api::BaseController
include ApiKeyable
before_action :deny_shared_cache
def show
authenticate_or_request_with_http_basic do |username, password|
user = User.authenticate(username.strip, password)
check_mfa(user) do
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
end
end
end
# Added to Api::BaseController by the fix.
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
Engineering & System Hardening Checklist
- ✓If you used RubyGems.org legacy credentials, replace them: RubyGems revoked all legacy keys. Audit gem versions, unexpected yanks, owners, trusted publishers, and webhooks; do not infer that existing releases were rewritten.
- ✓Use scoped API keys with only the required gem and actions, enable MFA for both UI and API actions, and prefer trusted publishing/OIDC over long-lived CI secrets. Confirm CI can publish only the intended gem from the intended workflow.
- ✓For authenticated or secret-bearing responses at a CDN, set Cache-Control: private, no-store at the origin, disable surrogate caching, and include every authorization dimension in Vary/cache-key policy. Test with compression enabled and disabled through the real CDN path.
- ✓Add an integration test that requests the credential endpoint with Accept-Encoding: gzip and asserts private, no-store, Surrogate-Control: max-
age=0, and Vary: Authorization. Also verify a second identity cannot receive the first response from a shared edge cache. - ✓When fixing a cache leak, purge cached objects before issuing replacement secrets; review historical logs as bounded evidence, not proof that no exposure occurred. Record which time window is actually observable.
- ✓Keep attribution precise: RubyGems did not confirm AI authorship of the May 2026 package campaign and found no evidence that API-key theft attempts succeeded.
Sources
- RubyGems — Security advisory: Possible leak of legacy API keys via improper cache configuration
- RubyGems.org — GHSA-9j48-x3c3-mrp2
- RubyGems.org — remediation commit d3d11c0
- Truffle Security — Cache vulnerability in RubyGems
- RubyGems — API key scopes
- RubyGems — May spam-publishing campaign update
- CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
- CWE-524 — Use of Cache Containing Sensitive Information