EnglishРусский Map
Supply Chain Security

Паттерн Pwn Request

title
Паттерн Pwn Request
type
concept
summary
GitHub pull_request_target исполняет код из форка в контексте прав основного репозитория
tags
security, github-actions, ci-cd
created
2026-05-12
updated
2026-05-12
lang
ru
translation_of
pwn-request-pattern
source_updated
2026-05-12
translated
2026-09-01
translator
lllm/antigravity/gemini-3.7-flash-medium

В GitHub Actions есть два триггера для PR. pull_request запускается на коде форка с областью видимости секретов форка (по умолчанию пустой) и требует одобрения для новых участников. pull_request_target запускается в контексте целевого репозитория с его секретами и не требует такого одобрения, поскольку изначально проектировался для доверенных операций (навешивание меток, комментарии, отправка превью развёртывания) над недоверенными PR.

Паттерн "pwn request" - это workflow с триггером pull_request_target, который где-то внутри исполняет код из форка. Классический вид:

on:
  pull_request_target:

jobs:
  build:
    steps:
      - uses: actions/checkout@v…
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - run: <build / test / lint>

Строка с checkout - загрузка ссылки merge из PR (или ветки форка) - подтягивает код из форка. Шаг run запускает его на исполнение. Поскольку триггером служит pull_request_target, workflow получает права GITHUB_TOKEN целевого репозитория, его секреты и (нередко) id-token: write. Неизвестный человек только что получил выполнение произвольного кода в доверенном контексте вашего репозитория.

GitHub Security Lab назвала это паттерном "pwn request" в 2021 году, и название прижилось.

Что делает его ещё опаснее

Масштаб проблемы часто недооценивают из-за двух неочевидных фактов:

  • Блок permissions: контролирует не всё. Сохранение кэша в actions/cache@v… по завершении шага использует внутренний токен runner'а, а не GITHUB_TOKEN из workflow. Конфигурация permissions: contents: read не запрещает запись в кэш. См. github-actions-cache-poisoning.
  • Разделение доверия внутри одного workflow даёт утечку. Если выставить permissions: contents: read и рассчитывать, что "сборка не доверена, а отправка комментария доверена", получается разделение намерений, а не разделение исполнения. Сборка всё равно идёт в окружении целевого репозитория, может прочитать всё содержимое диска в образе runner'а и записать данные в кэш.

Защита с помощью двух workflow

Рекомендуемое решение - структурное. Разделение на два workflow:

  1. Workflow с pull_request_target, делающий только то, для чего pull_request_target задумывался: метки, комментарии, проверки. Он не делает checkout кода из форка. Он не запускает код из форка. Если ему нужно инициировать сборку, он вызывает workflow_dispatch только после явного ручного подтверждения человеком.

  2. Workflow с триггером pull_request (или workflow_dispatch), который действительно собирает проект. Он запускается на коде форка без секретов репозитория и требует подтверждения для первого запуска от нового участника. Всё, что требует настоящих секретов для валидации, выполняется только после подтверждения человеком.

Инцидент с TanStack произошёл именно из-за попытки совместить оба подхода в одном файле с разделением доверия внутри него. По замыслу это было верно, но механизм оказался недостаточным. Как только внутри контекста pull_request_target выполнился actions/checkout ссылки merge из форка, последствия стали неизбежными.

Обнаружение

Известные инциденты

  • 2026-05-11 - TanStack. bundle-size.yml запускался по pull_request_target и собирал код форка. Кэш был отравлен. Полная цепочка описана в tanstack-npm-supply-chain-postmortem.
  • 2025-03 - tj-actions/changed-files. Немного другой вектор (force-push тега в незафиксированном action), но тот же итог: выполнение кода в релизных workflow зависимых проектов, кража токенов OIDC.

См. также