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: утечка API-ключа через CDN-кэш

Сбой gzip/CDN раскрыл старые ключи RubyGems. Здесь описаны исправление и восстановление; связь отдельной кампании с ИИ-агентами оспаривается.

💡 Простыми словами (ELI5)

Представьте стойку, где каждому проверенному клиенту выдают новый мастер-ключ, а копию кладут в общую корзину: упаковочная машина изменила маркировку конверта. Следующий посетитель той же стойки мог получить ключ предыдущего клиента без проверки удостоверения. Исправление запрещает общим кэшам хранить такие приватные ответы и разделяет ответы по данным авторизации.

Ключевые понятия и термины

Legacy API key
Долговременный ключ RubyGems с широкими правами управления gem-пакетами. По бюллетеню, он позволял публиковать новую версию, снимать версии, добавлять владельцев и менять вебхуки или настройки доверенной публикации; войти в учетную запись или переписать уже опубликованный релиз с его помощью было нельзя.
Shared cache / POP
Точка присутствия CDN сохраняет ответ для повторного использования. Если аутентифицированный ответ с секретом попадает в общий кэш без приватной политики и ключа кэша, учитывающего авторизацию, другой пользователь может получить этот ответ.
Compression middleware
RubyGems сообщает, что Accept-Encoding: gzip запускал взаимодействие middleware Rack, из-за которого ответ оставался без директивы private и без Vary: Authorization. Обычные запросы без gzip не давали тех же заголовков.
CWE-200 and CWE-524
В GitHub Security Advisory указаны CWE-200 — раскрытие чувствительной информации и CWE-524 — использование кэша с чувствительной информацией. Они описывают раскрытие и границу кэширования; CVE не присваивался.
RubyGems package-flood attribution
RubyGems подтвердила злоупотребление публикацией пакетов в мае 2026 года, но не смогла установить, создавали или публиковали ли их ИИ-агенты. Не смешивайте кампанию с ошибкой кэша: источники не подтверждают, что кампания привела к утечке ключей.

Связи со стандартами

  • Ссылка CWE для слабого места, описанного в этом руководстве: CWE-200 CWE-524

Хронология инцидента

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 отдельно описала майскую кампанию публикации пакетов и заявила, что не может приписать ее ИИ-агентам.

Пошаговый сценарий атаки

Шаг 1

Старый маршрут выдает учетные данные

Аутентифицированный GET-запрос к /api/v1/api_key создает и возвращает старый API-ключ с широкими правами. RubyGems указывает, что клиенты gem до версии 3.2.0 использовали этот маршрут; сервер сохранил его и после перехода новых клиентов на ключи с ограниченными правами.

Шаг 2

Gzip меняет путь формирования заголовков

Клиент запрашивает gzip. RubyGems связывает кэширование с взаимодействием Rack::Deflater и Rack::ETag: сжатый ответ получал только простой Cache-Control: no-cache, без private и без Vary: Authorization.

Шаг 3

CDN повторно использует аутентифицированный ответ

В затронутой конфигурации Fastly ответ мог храниться в точке присутствия до часа. Ключ кэша не разделял клиентов по Authorization, поэтому edge-узел мог отдать предыдущий ответ, не обращаясь к RubyGems для проверки следующего клиента.

Шаг 4

Другой клиент получает ключ

Запрос с тем же вариантом кэша, прошедший через ту же точку присутствия, мог получить ключ предыдущего пользователя. По словам RubyGems, это могло произойти случайно между входами; компания также описывает неаутентифицированное получение ответа из кэша. Доступные журналы не выявили злонамеренного использования, но охватывали лишь ограниченный период.

Шаг 5

Права ключа ограничивают последствия

Утекший старый ключ мог разрешить новые версии gem, снятие версий, смену владельцев и изменение вебхуков или доверенных издателей. Уже опубликованные релизы нельзя было переписать. RubyGems сообщает, что MFA для действий и в интерфейсе, и в API блокирует операции публикации, снятия и смены владельца при наличии только ключа.

Шаг 6

Исправление исключает общее кэширование и отзывает ключи

Патч добавляет Cache-Control: private, no-store, Surrogate-Control: max-age=0 и Vary: Authorization. RubyGems очистила кэш Fastly, отозвала старые ключи и отключила GET-вход. Рекомендации: ключи с ограниченными правами, MFA для API и доверенная публикация для CI.

Исходный код: Уязвимый vs Защищённый вариант

Фрагмент контроллера до исправления восстановлен по удаленным строкам публичного патча RubyGems; небезопасные заголовки кэша возникали из-за взаимодействия middleware Rack, а не из-за публичной политики кэширования в этом действии. Защитный callback и значения заголовков соответствуют коммиту d3d11c0. private, no-store запрещает общее хранение, Surrogate-Control отключает кэширование прокси, а Vary: Authorization разделяет ответы в кэше, если он все же сохраняет их. Это анализ исходного кода, а не инструкция по запросам и не автономный патч: применяйте полное исправление поставщика и очищайте старые записи кэша.

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
class Api::V1::ApiKeysController < Api::BaseController
  include ApiKeyable

  # В контроллере до исправления отсутствовал 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
БЕЗОПАСНЫЙ ИСПРАВЛЕННЫЙ ВАРИАНТ
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

Чек-лист по защите системы для инженеров

Источники