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 · Alta
Investigación · FlawOpen

RubyGems: fuga de claves API por caché CDN

Un fallo gzip/CDN expuso claves de RubyGems. Consulta la corrección y la recuperación; no se confirmó la autoría de IA en la otra campaña.

💡 Explicación en Lenguaje Sencillo (ELI5)

Imagina un mostrador que crea una llave maestra para cada cliente verificado y deja una copia en una bandeja común porque una máquina de embalaje cambió la etiqueta del sobre. La siguiente persona atendida en ese mismo mostrador podría llevarse la llave del cliente anterior sin identificarse. La corrección impide que las cachés compartidas almacenen respuestas privadas y separa las respuestas según la autorización.

Conceptos Clave y Términos

Legacy API key
Una credencial duradera de RubyGems con amplios permisos de gestión de gems. El aviso indica que podía publicar nuevas versiones, retirar versiones, añadir propietarios y cambiar webhooks o ajustes de trusted publishing; no permitía iniciar sesión en la cuenta ni reescribir una versión ya publicada.
Shared cache / POP
Un punto de presencia de la CDN almacena una respuesta para reutilizarla en peticiones posteriores. Si una respuesta autenticada con una credencial se comparte sin una política privada y una clave de caché que tenga en cuenta la autorización, otra persona puede recibirla.
Compression middleware
RubyGems explica que Accept-Encoding: gzip activó una interacción de Rack que dejó la respuesta sin la directiva private y sin Vary: Authorization. Las peticiones habituales sin gzip no mostraban el mismo comportamiento de cabeceras.
CWE-200 and CWE-524
El GitHub Security Advisory asigna CWE-200 a la exposición de información sensible y CWE-524 al uso de una caché con información sensible. Describen la divulgación y el límite de caché; no se asignó un CVE.
RubyGems package-flood attribution
RubyGems confirmó publicaciones abusivas de paquetes en mayo de 2026, pero dijo que no pudo determinar si agentes de IA los crearon o publicaron. Separa esta campaña del fallo de caché: las fuentes no demuestran que causara la exposición de claves.

Mapeos de marcos

  • Referencia CWE para la debilidad descrita en esta guía: CWE-200 CWE-524

Cronología del Incidente

2020-12-10

RubyGems 3.2.0 cambió el flujo normal de gem signin para crear claves con alcance, pero el servidor mantuvo disponible la ruta GET heredada.

2026-07-06

Truffle Security notificó el fallo de caché a RubyGems.

2026-07-09

RubyGems desplegó la corrección de cabeceras y purgó los objetos afectados de la caché de Fastly.

2026-07-22

RubyGems publicó GHSA-9j48-x3c3-mrp2 y dijo que revocó todas las claves heredadas. GHSA asigna 7,3 High; RubyGems informa de una puntuación ambiental global de 7,2 High.

2026-09-11

RubyGems describió por separado la campaña de publicación de paquetes de mayo y dijo que no podía atribuirla a agentes de IA.

Flujo de Ataque Paso a Paso

Paso 1

Una ruta heredada emite una credencial

Una petición autenticada GET a /api/v1/api_key crea y devuelve una clave heredada con amplios permisos. RubyGems afirma que los clientes gem anteriores a 3.2.0 usaban esa ruta; el servidor la mantuvo después de que los clientes nuevos pasaran a crear claves con alcance.

Paso 2

Gzip cambia el camino de las cabeceras

El cliente solicita gzip. RubyGems atribuye el comportamiento de caché a la interacción de Rack::Deflater y Rack::ETag: la respuesta comprimida terminaba con un Cache-Control: no-cache básico, sin private y sin Vary: Authorization.

Paso 3

La CDN reutiliza una respuesta autenticada

Con la configuración afectada de Fastly, la respuesta podía almacenarse en un POP hasta una hora. La clave de caché no separaba clientes por Authorization, así que el borde podía devolver la respuesta previa sin volver al origen para autenticar al siguiente cliente.

Paso 4

Otro usuario recibe la clave

Una petición con la misma variante de caché que llegara al mismo POP podía recibir la clave del usuario anterior. RubyGems dice que podía ocurrir accidentalmente entre inicios de sesión y describe también consultas no autenticadas de una respuesta almacenada. No halló indicios de uso malicioso en los registros disponibles, pero solo cubrían un periodo limitado.

Paso 5

El alcance de la clave limita las consecuencias

Una clave heredada filtrada podía autorizar nuevas versiones de gems, retiradas, cambios de propietarios y cambios en webhooks o trusted publishers. Las versiones publicadas no podían reescribirse. RubyGems indica que MFA exigida tanto en la interfaz como en la API bloquea acciones de publicación, retirada y cambio de propietarios usando solo la clave.

Paso 6

La corrección evita el uso compartido y retira las claves expuestas

El parche añade Cache-Control: private, no-store, Surrogate-Control: max-age=0 y Vary: Authorization. RubyGems purgó objetos de Fastly, revocó las claves heredadas y retiró la ruta GET de inicio de sesión. Recomienda claves con alcance, MFA en la API y trusted publishing para CI.

Código Fuente: Vulnerable vs. Seguro

El fragmento vulnerable del controlador se reconstruye a partir del lado eliminado de la corrección pública de RubyGems; las cabeceras de caché inseguras surgían de la interacción del middleware Rack, no de una política pública establecida por esta acción. El callback y las cabeceras reforzadas coinciden con el commit d3d11c0. private, no-store impide el almacenamiento compartido, Surrogate-Control desactiva la caché de intermediarios y Vary: Authorization separa cualquier caché que aún almacene una respuesta. Es análisis del código fuente, no una receta de petición ni un parche independiente; aplica la corrección completa del proveedor y purga las entradas anteriores.

IMPLEMENTACIÓN VULNERABLE
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  # El controlador anterior a la corrección no tenía la llamada de retorno 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
PARCHE SEGURO Y ROBUSTO
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

# Añadido a Api::BaseController mediante la corrección.
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

Lista de Verificación de Seguridad para Ingeniería

Fuentes