Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Core Concepts and Mental Model
Build a precise trust-boundary model for contributor pull requests, fork restrictions, privileged triggers, checkout identity and safe side effects.
Learning objectives
- Explain why pull-request content, metadata and artifacts are inputs from a lower-trust principal.
-
Distinguish
pull_requestfrompull_request_targetby workflow revision, ref/SHA and authority. - Track base repository, head repository, merge/head SHA, checked-out SHA, token scope and secret availability separately.
- Recognize where runner choice and privileged side effects convert a harmless PR into a security boundary.
- Use evidence—not a green checkmark—to prove which code and authority actually met on a runner.
1. The practical problem: CI must inspect code it does not yet trust
A pull request exists precisely because proposed code has not yet been accepted. In a fork-based contribution model, the author may have no write access to the base repository at all. That makes the pull request head commit, branch name, title, body, changed files, build scripts, dependency manifests and any artifacts produced from them untrusted input. The CI system still needs to compile and test that material, but it must not accidentally combine it with base-repository secrets, a write-capable token or a trusted internal runner.
Earlier chapters separated event, workflow revision, token, runner and deployment state. Chapter 22 joins those ideas into a security invariant: untrusted code may execute only inside an authority envelope that is safe to lose. A privileged workflow may perform a narrow metadata operation only if it never executes, sources or implicitly trusts contributor-controlled code.
2. Define the state before the first checkout
| State | Question to record | Why it matters |
|---|---|---|
| Event |
pull_request, pull_request_target,
workflow_run?
|
The trigger determines workflow revision, default ref/SHA and trust assumptions. |
| Base/head identity | Base repository + base SHA; head repository + head SHA | A fork head is a different trust principal from the base repository. |
| Checked-out code | Exact SHA actually placed in the workspace | The event SHA and the executed source SHA are not always the same thing. |
| Token/secrets |
Effective GITHUB_TOKEN permissions; which
secret classes exist
|
Authority must be bounded before untrusted code runs. |
| Runner | GitHub-hosted ephemeral or self-hosted/persistent | Runner compromise can outlive a single job on persistent infrastructure. |
| Untrusted data | Titles, bodies, refs, paths, artifacts, generated files | Data becomes code if interpolated into shell or executed after download. |
| Side effect | Check only, comment/label, package, deploy, cloud/API write | A green check and a privileged mutation are different outcomes. |
| Approval/policy | External-contributor approval and fork settings | Approval can gate compute; it does not sanitize code or make it trusted. |
3. Mental model: keep untrusted execution and privileged authority apart
Read the following graph top to bottom. The contributor controls the PR head and much of the event payload. The low-privilege path can execute that code on an ephemeral hosted runner. The privileged path starts from trusted base-branch workflow code and is permitted to mutate metadata only if it never crosses back into executing fork-controlled material.
flowchart TD
A[Contributor PR head + metadata
untrusted] --> B{Which operation?}
B -->|Build / test PR code| C[pull_request
restricted authority]
C --> D[GitHub-hosted ephemeral runner]
D --> E[Checkout event merge/head as intended]
E --> F[Tests and read-only evidence]
B -->|Comment / label metadata| G[pull_request_target or validated follow-up
trusted base workflow]
G --> H[Least-privilege token]
H --> I[Metadata-only logic
NO fork checkout or execution]
I --> J[Narrow API side effect]
F --> K[Preserve run ID / attempt / SHA]
J --> K
4. pull_request: execute proposed code inside the
restricted path
For fork pull requests, GitHub applies a restricted security model
to ordinary pull_request workflows: the
GITHUB_TOKEN is read-only by default and normal Actions
secrets are not passed. Dependabot-triggered pull-request workflows
are also treated as fork-like for these restrictions. Public
repositories may additionally require a maintainer to approve the
workflow run for an external contributor before compute starts.
By default, actions/checkout in a
pull_request workflow checks out the pull request merge
commit, keeping the tested tree aligned with the event's
merge-oriented CI semantics. If your test policy requires the exact
head commit instead, make that choice explicit and record the
resulting checked-out SHA; do not casually treat
github.sha, the head SHA and the merge SHA as
interchangeable.
name: PR checks — low privilege
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Record execution identity
shell: bash
run: |
printf 'run=%s attempt=%s event=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_EVENT_NAME"
printf 'checked_out=%s\n' "$(git rev-parse HEAD)"
- name: Run repository tests
run: ./scripts/test.sh
5. pull_request_target: privileged base context, not
“PR CI with secrets”
pull_request_target runs the workflow from the base
repository's default branch. Its GITHUB_REF is the
default branch and its GITHUB_SHA is the last commit on
that branch. Because this is trusted base code, the event can
receive the base repository's normal authority. That makes it useful
for operations such as controlled labeling or commenting on fork
PRs—and dangerous if the workflow fetches contributor code and
executes it.
Current actions/checkout adds an explicit guard for
this trust boundary. In v7.0.1, checking out fork pull-request code
from a pull_request_target or
workflow_run context requires the conspicuous
allow-unsafe-pr-checkout: true opt-out. The safe
default is not a substitute for design review: fetching by
git, another action, an archive URL or an artifact can
cross the same boundary.
Security boundary: never use
pull_request_target as a shortcut to “run PR tests with
secrets.” The dangerous combination is privileged authority +
attacker-controlled code execution. The checkout itself is only one
possible route for introducing that code.
6. Event metadata is data, not shell source
A contributor can control fields that look harmless: pull-request title/body, branch name, labels they can influence, commit messages and file names. If an expression substitutes one of those strings directly while GitHub is generating an inline shell script, shell metacharacters can become syntax. Pass the expression into an environment variable or action input, then quote and validate the value as data.
# Unsafe shape — teaching example only; do not run with real PR metadata
- name: Unsafe title handling
run: echo "title=${{ github.event.pull_request.title }}"
# Safe shape
- name: Treat title as data
env:
PR_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
printf 'title_length=%s\n' "${#PR_TITLE}"
if [[ "$PR_TITLE" =~ ^docs: ]]; then
echo "classification=docs"
else
echo "classification=other"
fi
7. Runner trust is part of the PR security model
A GitHub-hosted runner is ephemeral at the job boundary. It may still execute malicious code during the job, but the environment is discarded afterward. A persistent self-hosted runner has a different blast radius: untrusted PR code may probe local credentials, internal services, neighboring build state or persistence mechanisms. GitHub's secure-use guidance therefore recommends against self-hosted runners for public repositories and demands strong isolation if untrusted code can reach a self-hosted fleet.
Environment approvals do not cure this problem. They can delay environment-secret access, but they do not make a persistent machine safe after attacker-controlled code has executed on it.
8. Artifacts from low-trust workflows remain untrusted
Privilege separation often uses two workflows: a low-privilege
workflow builds/tests the PR, and a later
workflow_run workflow performs a privileged operation.
This can be safer than pull_request_target, but only if
the handoff is treated as an untrusted protocol. A success
conclusion is not proof that an uploaded artifact is harmless. The
privileged workflow must bind the artifact to the expected upstream
run/repository/SHA, validate its type and contents, and avoid
executing files supplied by the untrusted workflow.
Current GitHub guidance explicitly warns that
workflow_run can access secrets and write tokens even
if the previous workflow could not. Its own
GITHUB_REF/GITHUB_SHA refer to the default
branch, and the triggering workflow identity is found under
github.event.workflow_run. That distinction must appear
in the evidence packet.
9. Dependabot is a special low-trust actor, not an exception to the model
Dependabot-triggered workflows deserve explicit testing. For several
ordinary events, GitHub gives Dependabot a read-only
GITHUB_TOKEN; GitHub Actions secrets are not supplied,
and Dependabot secrets are a separate store. Current documentation
also applies a protective exception to
pull_request_target when the base ref was created by
Dependabot: the token is read-only and secrets are withheld. Do not
write a security design that assumes “pull_request_target
always means write token + secrets.”
10. Misconceptions to eliminate
| Wrong shortcut | Why it fails | Safer invariant |
|---|---|---|
| “A maintainer approved the run, so the PR code is trusted.” | Approval gates compute; it does not review every executable path. | Still run fork code with restricted authority. |
| “The workflow is from default branch, so checking out the fork is safe.” | The next build/install/test step can execute fork-controlled code with privileged authority. | Metadata-only privileged workflow; no fork execution. |
| “A green upstream workflow makes its artifact trusted.” | Untrusted code can deliberately produce malicious data while tests pass. | Validate identity, schema/content and intended use; never blindly execute. |
| “Read-only token means self-hosted runner is safe.” | The runner host/network may contain authority outside GitHub. | Use isolated ephemeral hosted compute for untrusted code. |
11. What Chapter 22 adds to the operating model
The production operating model now has an explicit trust transition. Before any job executes, operators can answer who controls the event, which workflow revision GitHub selected, which exact source SHA enters the workspace, what token/secrets exist, what runner receives the code, what data crosses to later workflows and which privileged side effect is authorized. That is stronger than labeling a workflow “CI” or “trusted.”
12. Lesson summary
Use pull_request for untrusted PR code execution, with
minimal permissions and ephemeral hosted runners. Use privileged
triggers only for the narrow operations that require them, and keep
those workflows on trusted base code without executing fork content.
Treat metadata and artifacts as attacker-controlled data until
validated.
Knowledge check
Why is pull_request_target not a drop-in
replacement for pull_request when tests need
secrets?
Because it runs with trusted base-repository authority. Fetching and executing fork code in that context combines attacker-controlled code with privileged tokens/secrets—the pwn-request pattern.
A public fork PR workflow is awaiting maintainer approval. Does approval make its code trusted?
No. Approval authorizes the workflow to consume compute. The code remains untrusted and must still execute inside the restricted authority boundary.
Why should the evidence packet record the checked-out SHA
separately from github.sha?
Different PR events can give
github.sha merge/default-branch semantics.
Recording git rev-parse HEAD proves which code
actually executed.
Can a successful low-privilege workflow make its uploaded artifact safe for a privileged workflow to execute?
No. Success is a workflow conclusion, not an artifact trust guarantee. Validate provenance and contents, and avoid executing fork-produced artifacts in privileged contexts.
What is the safest default runner class for arbitrary public-fork tests?
An ephemeral GitHub-hosted runner with restricted GitHub permissions; persistent self-hosted infrastructure should not be exposed to arbitrary public-fork code.
Official references and version notes
- Secure use reference — GitHub guidance for untrusted input, least privilege, action pinning and risky privileged triggers.
- Securely using pull_request_target — Current guidance on privileged PR workflows and checkout protections.
- Events that trigger workflows — Authoritative event, ref/SHA, fork, Dependabot and workflow_run semantics.
- Script injections — Why event-controlled text must not become shell source.
- Approving workflow runs from forks — Current external-contributor approval behavior.
- Managing Actions settings for a repository — Repository controls for fork workflows and token/secrets behavior.
- actions/checkout v7.0.1 action metadata — Node 24 checkout metadata including current unsafe-PR-checkout guard.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.