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 · Haute
Recherche · FlawOpen

RubyGems : fuite de clé API par cache CDN

Une faille gzip/CDN a exposé des clés RubyGems. Découvrez le correctif ; le lien avec la campagne distincte d’agents IA reste contesté.

💡 Explication en Langage Simple (ELI5)

Imaginez un guichet qui crée une clé maîtresse pour chaque client vérifié, puis en laisse une copie dans un bac partagé parce qu’une machine d’emballage a modifié l’étiquette de l’enveloppe. La personne suivante au même guichet pourrait récupérer la clé du client précédent sans présenter de pièce d’identité. Le correctif empêche les caches partagés de stocker ces réponses privées et distingue les réponses selon l’autorisation.

Concepts Clés et Termes

Legacy API key
Un identifiant RubyGems durable aux permissions étendues de gestion des gems. L’avis indique qu’il pouvait publier une nouvelle version, retirer des versions, ajouter des propriétaires et modifier les webhooks ou les paramètres trusted publisher ; il ne permettait ni de se connecter au compte ni de réécrire une version déjà publiée.
Shared cache / POP
Un point de présence de la CDN stocke une réponse pour la réutiliser lors de requêtes ultérieures. Si une réponse authentifiée contenant un secret est partagée sans politique privée ni clé de cache tenant compte de l’autorisation, un autre utilisateur peut la recevoir.
Compression middleware
RubyGems indique que Accept-Encoding: gzip déclenchait une interaction Rack qui laissait la réponse sans directive private et sans Vary: Authorization. Les requêtes ordinaires sans gzip ne présentaient pas le même comportement d’en-tête.
CWE-200 and CWE-524
Le GitHub Security Advisory associe CWE-200 à l’exposition d’informations sensibles et CWE-524 à l’utilisation d’un cache contenant des informations sensibles. Ces faiblesses décrivent la divulgation et la frontière du cache ; aucun CVE n’a été attribué.
RubyGems package-flood attribution
RubyGems a confirmé des publications abusives de gems en mai 2026, mais dit ne pas pouvoir déterminer si des agents IA les ont créées ou publiées. Distinguez cette campagne de la faille de cache : les sources n’établissent pas qu’elle ait causé une fuite de clé.

Correspondances de référentiels

  • Référence CWE pour la faiblesse décrite dans ce guide : CWE-200 CWE-524

Chronologie de l'Incident

2020-12-10

RubyGems 3.2.0 a remplacé le flux standard gem signin par la création de clés à portée limitée, mais le serveur a conservé l’ancienne route GET.

2026-07-06

Truffle Security a signalé le problème de cache à RubyGems.

2026-07-09

RubyGems a déployé le correctif des en-têtes et purgé les objets concernés du cache Fastly.

2026-07-22

RubyGems a publié GHSA-9j48-x3c3-mrp2 et annoncé la révocation de toutes les clés historiques. GHSA attribue 7,3 High ; RubyGems indique une note environnementale globale de 7,2 High.

2026-09-11

RubyGems a décrit séparément la campagne de publication de gems de mai et déclaré ne pas pouvoir l’attribuer à des agents IA.

Déroulement de l'Attaque Étape par Étape

Étape 1

Une ancienne route émet un identifiant

Une requête authentifiée GET vers /api/v1/api_key crée et renvoie une clé historique aux permissions étendues. RubyGems indique que les clients gem antérieurs à 3.2.0 utilisaient cette route ; le serveur l’a conservée après le passage des nouveaux clients aux clés à portée limitée.

Étape 2

Gzip modifie le chemin des en-têtes

Le client demande gzip. RubyGems attribue le comportement du cache à l’interaction Rack::Deflater/Rack::ETag : la réponse compressée n’avait qu’un Cache-Control: no-cache simple, sans private ni Vary: Authorization.

Étape 3

La CDN réutilise une réponse authentifiée

Dans la configuration Fastly concernée, la réponse pouvait rester dans un POP jusqu’à une heure. La clé du cache ne séparait pas les clients selon Authorization ; le nœud périphérique pouvait donc renvoyer l’ancienne réponse sans contacter RubyGems pour authentifier le client suivant.

Étape 4

Un autre client reçoit la clé

Une requête utilisant la même variante de cache et passant par le même POP pouvait recevoir la clé du client précédent. RubyGems précise que cela pouvait arriver accidentellement entre deux connexions et décrit aussi l’interrogation non authentifiée d’une réponse en cache. Les journaux disponibles ne montrent pas d’usage malveillant, mais ne couvrent qu’une période limitée.

Étape 5

La portée de la clé limite les conséquences

Une clé historique divulguée pouvait autoriser de nouvelles versions de gems, des retraits, des changements de propriétaires et des modifications de webhooks ou de trusted publishers. Les versions publiées ne pouvaient pas être réécrites. Selon RubyGems, la MFA exigée pour l’interface et l’API bloque les actions push, yank et propriétaire avec la seule clé.

Étape 6

Le correctif empêche le partage et retire les clés exposées

Le correctif ajoute Cache-Control: private, no-store, Surrogate-Control: max-age=0 et Vary: Authorization. RubyGems a purgé les objets Fastly, révoqué les clés historiques et retiré l’ancienne route GET. L’équipe recommande les clés à portée limitée, la MFA API et trusted publishing pour la CI.

Code Source : Vulnérable vs Sécurisé

L’extrait du contrôleur vulnérable est reconstitué à partir des lignes supprimées dans le correctif public de RubyGems ; les en-têtes de cache dangereux provenaient de l’interaction du middleware Rack, pas d’une politique de cache publique définie dans cette action. Le callback et les valeurs d’en-tête renforcés correspondent au commit d3d11c0. private, no-store interdit le stockage partagé, Surrogate-Control désactive le cache des proxys et Vary: Authorization distingue toute réponse encore stockée. Cette analyse du code n’est ni une procédure de requête ni un correctif autonome ; appliquez le correctif complet du fournisseur et purgez les anciennes entrées.

IMPLÉMENTATION VULNÉRABLE
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  # Le contrôleur avant correctif ne comportait pas de callback 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
PATCH SÉCURISÉ ET ROBUSTE
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

# Ajouté à Api::BaseController par le correctif.
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

Liste de Contrôle de Sécurité pour l'Ingénierie

Sources