pull_request_target и untrusted PR
Почему `pull_request_target` опасен при checkout кода из PR, как разделять read-only validation и privileged automation, approvals from forks и безопасные паттерны.
pull_request_target и untrusted PR
pull_request_target — полезный, но security-sensitive trigger. Он запускает workflow на событие pull request, но делает это в контексте base repository: workflow-файл берётся из доверенной ветки репозитория, а не из PR. Поэтому GitHub разрешает сценарии вроде «поставить label», «оставить comment», «обновить metadata PR» даже для PR из fork. Цена этой возможности — повышенный риск: если в таком workflow выполнить код из PR, untrusted contributor может получить доступ к тому, что было предназначено только для доверенного workflow.
Короткое правило: pull_request — для build/test кода из PR; pull_request_target — для privileged automation вокруг PR, но не для запуска PR-кода. Это продолжение модели угроз из Secure use и threat model: событие PR приносит данные от внешнего автора, а workflow остаётся исполняемым кодом с token, network и side effects.
flowchart TD
A[PR из fork] --> B{Что нужно сделать?}
B -->|Собрать или протестировать PR-код| C[pull_request]
C --> D[contents: read]
D --> E[checkout PR-кода]
E --> F[npm ci / tests / reports]
B -->|Label, comment, triage metadata| G[pull_request_target]
G --> H[явные permissions]
H --> I[не checkout'ить head PR]
I --> J[GitHub API: comment, label, reviewer]
B -->|Privileged deploy или release| K[после merge / protected environment]
K --> L[environment approval + OIDC]
style C fill:#e8f5e9,stroke:#2e7d32
style G fill:#fff8e1,stroke:#f9a825
style K fill:#e3f2fd,stroke:#1565c0Где именно появляется опасность
В обычном pull_request workflow GitHub ограничивает fork PR: secrets не передаются, а GITHUB_TOKEN для fork PR имеет read-only права. Это хорошо подходит для CI: checkout, install, test, reports. Подробности такой механики лежат рядом с CI pipeline, Artifacts и reports и Permissions и GITHUB_TOKEN.
pull_request_target устроен иначе. Он выполняется как workflow base repository и может получить secrets или write-capable token, если вы не сузили permissions. Сам по себе trigger не «дырка»: он нужен, чтобы доверенный workflow реагировал на PR. Дырка появляется, когда workflow явно checkout’ит head PR и затем запускает install/build/test/script из этого checkout.
Классический опасный пример:
name: dangerous-pr-build
on: pull_request_target
permissions:
contents: write
pull-requests: write
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- run: npm ci
- run: npm test
- run: gh pr comment ${{ github.event.pull_request.number }} --body "Tests passed"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Здесь автор PR контролирует package.json, lifecycle scripts, test scripts, build config и любые локальные actions внутри PR. Если npm ci запускает postinstall, этот код уже выполняется в privileged job. Даже если вы не передали секрет явно, action или shell-команда может увидеть доступный token, credentials в git config, environment variables, workspace, cache и artifacts.
Разделение: validation отдельно, automation отдельно
Production-паттерн обычно состоит из двух workflow.
Первый — read-only validation. Он запускает PR-код, но не имеет write-доступа и production secrets:
name: pr-validation
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm testВторой — privileged automation. Он может писать comment или label, но не checkout’ит и не выполняет код из PR:
name: pr-triage
on:
pull_request_target:
types: [opened, reopened, labeled]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
comment:
runs-on: ubuntu-24.04
steps:
- name: Comment from trusted workflow
run: |
gh pr comment "$PR_NUMBER" --body "Спасибо за PR. CI запустится в read-only workflow."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}Обратите внимание на границу: privileged workflow использует metadata PR — номер, labels, author, base branch, но не запускает код из head. Если нужно читать diff, лучше использовать GitHub API или пассивно сравнивать файлы. «Пассивно» значит: не выполнять npm install, make, локальные scripts, test runner, formatter из PR и локальные actions из PR.
Что насчёт checkout только для diff
Иногда pull_request_target всё же checkout’ит PR: например, чтобы посчитать changed files, проверить наличие changelog или сгенерировать diff. Это не автоматически уязвимость, но требует жёсткого режима:
- name: Passive file inspection only
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename' | sed -n '1,200p'Даже такой вариант лучше держать без secrets, с contents: read, без cache write и без запуска инструментов из репозитория. persist-credentials: false важен, потому что checkout по умолчанию может сохранить token для дальнейших git-команд. Но это не превращает workflow в безопасный build: любой шаг, который интерпретирует файлы PR как программу, снова пересекает границу доверия.
Approvals from forks не спасают pull_request_target
GitHub умеет требовать approval перед запуском workflow для PR из public fork. Maintainer с write-доступом смотрит изменения, особенно .github/workflows/, и нажимает «Approve workflows to run». Это полезно против resource abuse и случайного запуска workflow от незнакомого contributor.
Но approval policy не является защитой для pull_request_target: такие workflow запускаются в контексте base branch и поэтому могут стартовать независимо от approval settings. Если вы думали «мы включили approval for first-time contributors, значит privileged workflow безопасен», это неверная модель. Approval помогает обычным pull_request runs; для pull_request_target безопасность должна быть заложена в YAML: permissions, отсутствие PR-code execution, аккуратная работа с contexts и secrets.
Есть ещё один edge case: label вроде safe-to-test как ручной gate. Его можно использовать как временную страховку, но не как финальную архитектуру. После label автор PR может успеть push’нуть новые commits до старта workflow, если вы не pin’ите конкретный reviewed SHA. Надёжнее запускать тесты через pull_request без привилегий, а privileged действие делать отдельно после review или после merge.
Безопасные сценарии для pull_request_target
pull_request_target уместен, когда job делает действие от имени base repository, но не исполняет PR-код:
- добавить label по author association, path metadata или PR title;
- написать onboarding comment;
- проверить, заполнен ли PR template, через GitHub API;
- назначить reviewer или milestone;
- закрыть PR по явно нарушающему policy условию;
- реагировать на merged PR через
types: [closed]иif: github.event.pull_request.merged == true.
Если задача звучит как «собрать», «протестировать», «запустить formatter», «опубликовать preview из кода PR», «прочитать package scripts», берите pull_request и минимальные права. Если после успешной проверки нужно privileged действие, используйте отдельный workflow и очень осторожно передавайте только проверенные данные. Для deploy-preview лучше привязать секреты к Environments и protection rules, а облачные credentials — к OIDC и секреты облаков, с условиями на branch, environment и trusted actor.
Практический audit-чеклист
Проверяя существующий repo, ищите такие красные флаги:
on: pull_request_target
# плюс что-то из этого:
- uses: actions/checkout@...
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci
- run: pip install -r requirements.txt
- run: make test
- uses: ./.github/actions/some-local-action
permissions: write-all
permissions:
contents: writeНе каждый checkout опасен одинаково, но сочетание pull_request_target + head checkout + execution step почти всегда требует redesign. Начните с простого: перенесите build/test в pull_request, поставьте permissions: contents: read, а pull_request_target оставьте только для comment/label. Затем проверьте Marketplace actions и pinning, потому что third-party action в privileged workflow получает ту же зону доступа, что и ваши shell steps.
See also
- Secure use и threat model — общая модель угроз для workflow, contexts, secrets и third-party actions.
- Events и filters — различия между triggers, activity types, branches и paths.
- Permissions и GITHUB_TOKEN — как сузить token на workflow и job level.
- Variables, env и secrets — почему secrets нельзя давать job, которая выполняет untrusted code.
- OIDC и секреты облаков — как не хранить долгоживущие cloud keys в GitHub.
- Runner governance — почему self-hosted runner особенно опасен для untrusted PR.
- Monitoring и troubleshooting — как расследовать подозрительные runs, logs и reruns.
Внешние справки: GitHub Docs по pull_request_target, approvals for fork workflows, secure use reference и статья GitHub Security Lab «Preventing pwn requests».
