FlawOpen

flawopen.com/Incidents/rubygems-cdn-cache-api-key-leak-july-2026

● RubyGems.org / Ruby Central · CWE-200 / CWE-524 · July 2026 · CVSS 7.3 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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-200 for exposure of sensitive information and CWE-524 for 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

  • CWE reference for the weakness described in this guide: CWE-200 CWE-524

Incident Timeline

2020-12-10

RubyGems 3.2.0 changed the standard gem signin flow to scoped-key creation, but the server kept the legacy GET endpoint available.

2026-07-06

Truffle Security reported the cache issue to RubyGems.

2026-07-09

RubyGems deployed the response-header fix and purged affected Fastly cache objects.

2026-07-22

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.

2026-09-11

RubyGems described the separate May package-publishing campaign and said it could not attribute it to AI agents.

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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.

Step 5

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.

Step 6

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.

UNPATCHED FLAW
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
HARDENED SECURE PATCH
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

Sources