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
一种长期有效、拥有广泛 gem 管理权限的 RubyGems 凭据。公告称它可发布新版本、撤回版本、添加所有者并修改 webhook 或可信发布者设置;它不能登录账户,也不能改写已发布的版本。
Shared cache / POP
CDN 的边缘节点会保存响应以供后续请求复用。如果包含凭据的认证响应没有私有缓存策略,也没有按授权信息区分的缓存键,其他调用者就可能拿到该响应。
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 称 3.2.0 之前的 gem 客户端使用此路由;新客户端改用受限范围密钥后,服务器仍保留了旧接口。

步骤 2

gzip 改变响应头处理路径

客户端请求 gzip。RubyGems 将缓存行为归因于 Rack::Deflater 与 Rack::ETag 的交互:压缩后的响应只带有普通的 Cache-Control: no-cache,缺少 private 和 Vary: Authorization。

步骤 3

CDN复用认证响应

在受影响的 Fastly 配置中,响应可能在边缘 POP 缓存长达一小时。缓存键没有按 Authorization 区分调用者,因此边缘节点可能直接返回先前的响应,而不回源验证下一位客户。

步骤 4

另一位调用者收到密钥

通过同一 POP、请求相同缓存变体的调用者可能收到前一位用户的密钥。RubyGems 表示,这可能在用户登录时意外发生;也描述了未认证地读取缓存响应的情况。现有日志未发现恶意使用,但只覆盖有限时间段。

步骤 5

密钥权限限制影响范围

泄露的旧密钥可用于发布新 gem 版本、撤回版本、更改所有者,以及修改 webhook 或可信发布者。已发布的版本无法被改写。RubyGems 表示,同时要求界面和 API MFA 可阻止仅凭密钥执行发布、撤回和所有者变更。

步骤 6

修复阻止共享复用并撤销暴露密钥

补丁加入 Cache-Control: private, no-store、Surrogate-Control: max-age=0 和 Vary: Authorization。RubyGems 清理了 Fastly 对象、撤销旧密钥并退役 GET 登录路由。建议使用受限密钥、启用 API MFA,并在 CI 中使用可信发布。

源代码对比:漏洞与安全实现

未修复控制器片段依据 RubyGems 公开修复中被删除的代码重建;不安全缓存头来自 Rack 中间件交互,而不是该控制器动作显式设置了公共缓存策略。加固后的 callback 和头部值与提交 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

工程与系统安全加固清单

参考来源