Chapter 22Lesson 01~155 minutes

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.

Trust boundaryFork PRLeast privilegeUntrusted inputBase vs head

Learning objectives

  • Explain why pull-request content, metadata and artifacts are inputs from a lower-trust principal.
  • Distinguish pull_request from pull_request_target by 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.

PR trust boundary
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.

Next lesson

Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Guided Hands-On Workflow

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why is pull_request_target not a drop-in replacement for pull_request when tests need secrets?

A public fork PR workflow is awaiting maintainer approval. Does approval make its code trusted?

Why should the evidence packet record the checked-out SHA separately from github.sha?

Can a successful low-privilege workflow make its uploaded artifact safe for a privileged workflow to execute?

What is the safest default runner class for arbitrary public-fork tests?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.