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 · 高
セキュリティ研究 · FlawOpen

RubyGems:CDNキャッシュによるAPIキー漏えい

gzip/CDNキャッシュの不備でRubyGemsの旧キーが漏えいする可能性がありました。修正と復旧策を解説し、別件のAI関与は未確定とします。

💡 わかりやすい解説 (ELI5)

本人確認を済ませた顧客ごとに合鍵を作る窓口を想像してください。包装機が封筒のラベルを変えたため、合鍵が共用トレーに置かれてしまいます。同じ窓口を次に使った人が、身分証を示さず前の顧客の鍵を受け取る可能性があります。修正では、共有キャッシュに機密応答を保存させず、認証情報ごとに応答を分けます。

主要な概念と専門用語

Legacy API key
RubyGemsのgem管理に広い権限を持つ、長期間有効な認証情報です。アドバイザリによると、新しいgem版の公開、版の取り下げ、所有者の追加、WebhookやTrusted Publisher設定の変更が可能でした。アカウントへのログインや公開済みリリースの書き換えはできません。
Shared cache / POP
CDNのPoint of Presence(POP)が応答を保存し、後続リクエストに再利用します。認証済み応答に秘密情報が含まれるのに、privateなキャッシュ方針と認証情報を考慮したキャッシュキーがなければ、別の利用者に応答が返るおそれがあります。
Compression middleware
RubyGemsによると、Accept-Encoding: gzipによってRackミドルウェア間の処理が変わり、応答にprivate指示とVary: Authorizationが付かない状態になりました。gzipを使わない通常のリクエストでは同じヘッダー挙動になりませんでした。
CWE-200 and CWE-524
GitHub Security Advisoryは、機密情報の漏えいにCWE-200、機密情報を含むキャッシュの使用にCWE-524を割り当てています。情報漏えいとキャッシュ境界を表し、CVEは割り当てられていません。
RubyGems package-flood attribution
RubyGemsは2026年5月の不正なパッケージ公開を確認しましたが、AIエージェントが作成・公開したかは判断できないとしています。 このキャンペーンとキャッシュ不備は別件です。キャンペーンがキー漏えいを引き起こしたという根拠はありません。

フレームワーク対応

インシデントのタイムライン

2020-12-10

RubyGems 3.2.0で通常のgem signinがスコープ付きキーの作成に切り替わりましたが、サーバーは旧GETエンドポイントを維持しました。

2026-07-06

Truffle Securityがキャッシュ不備をRubyGemsに報告しました。

2026-07-09

RubyGemsがレスポンスヘッダーを修正し、Fastly上の対象キャッシュを削除しました。

2026-07-22

RubyGemsがGHSA-9j48-x3c3-mrp2を公開し、旧キーをすべて失効させたと発表しました。GHSAの基本評価は7.3 High、RubyGemsの環境全体の評価は7.2 Highです。

2026-09-11

RubyGemsは5月のパッケージ公開キャンペーンを別途説明し、AIエージェントによるものとは断定できないとしました。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

旧エンドポイントが認証情報を発行

/api/v1/api_keyへの認証済みGETは、広い権限を持つ旧APIキーを作成して返します。RubyGemsによると、gem 3.2.0未満のクライアントがこの経路を使用していました。新しいクライアントがスコープ付きキーへ移行した後も、サーバー側のエンドポイントは残っていました。

ステップ 2

gzipがヘッダー処理を変える

クライアントがgzipを要求します。RubyGemsはRack::DeflaterとRack::ETagの連携を原因として挙げています。圧縮応答には単純なCache-Control: no-cacheだけが残り、privateとVary: Authorizationがありませんでした。

ステップ 3

CDNが認証済み応答を再利用

対象のFastly設定では、応答がPOPに最大1時間保存される可能性がありました。キャッシュキーはAuthorizationごとに呼び出し元を分離していなかったため、エッジがRubyGemsに戻って次の利用者を認証せず、前の応答を返すことがありました。

ステップ 4

別の利用者にキーが渡る

同じPOPに到達し同じキャッシュ条件になるリクエストには、前の利用者のキーが返る可能性がありました。RubyGemsはログイン間で偶発的に起きる場合に加え、未認証でキャッシュ応答を取得する可能性も説明しています。利用可能なログには悪用の証拠はありませんが、対象期間は限られています。

ステップ 5

キーの権限が影響範囲を決める

漏えいした旧キーは、新しいgem版の公開、版の取り下げ、所有者変更、WebhookやTrusted Publisher設定の変更に使えました。公開済みリリースは書き換えられません。RubyGemsによると、UIとAPIの両方でMFAを要求すれば、キーだけによる公開・取り下げ・所有者変更を防げます。

ステップ 6

修正で共有再利用を防ぎ、キーを失効

パッチはCache-Control: private, no-store、Surrogate-Control: max-age=0、Vary: Authorizationを追加します。RubyGemsはFastlyのキャッシュを削除し、旧キーを失効させ、GETログイン経路を廃止しました。スコープ付きキー、API MFA、CIでのTrusted Publishingを推奨しています。

ソースコード比較:脆弱 vs 堅牢化

未修正コントローラーの抜粋は、RubyGemsの公開修正で削除された行から再構成しています。問題のキャッシュヘッダーは、このアクションが公開キャッシュ方針を設定したためではなく、Rackミドルウェアの連携から生じました。修正後のcallbackとヘッダー値はcommit d3d11c0に一致します。private, no-storeは共有保存を防ぎ、Surrogate-Controlは中継キャッシュを無効化し、Vary: Authorizationは保存される場合も認証情報別に分離します。これはソース分析であり、リクエスト手順や単独適用できるパッチではありません。ベンダーの完全な修正を適用し、既存キャッシュを削除してください。

脆弱な実装
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  # 修正前のコントローラーには 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
堅牢化されたセキュアパッチ
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

# 修正で Api::BaseController に追加された。
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

エンジニアリング&システム堅牢化チェックリスト

参考資料