Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Guided Hands-On Workflow
Run a disposable fork-safety lab that separates untrusted CI from metadata-only privileged automation and fixes a harmless script-injection example.
Learning objectives
-
Build a disposable low-privilege
pull_requestworkflow and record exact PR/base/head/checkout identities. - Reproduce a harmless script-generation injection locally, then repair it by separating data from shell source.
-
Design a
pull_request_targetmetadata-only workflow that never checks out or executes the contributor tree. - Observe fork-token/secrets restrictions or perform a faithful local simulation when a second GitHub account/fork is unavailable.
- Produce a bounded evidence packet and clean up all disposable state.
1. Lab scenario: two lanes, two trust levels
Create a disposable public repository named
gha-pr-boundary-lab. The repository contains a tiny
shell test and two workflows. Lane A is ordinary
pull_request CI: it checks out the PR merge result on
ubuntu-24.04, runs the harmless test and has only
contents: read. Lane B is a
pull_request_target workflow that inspects PR metadata
as data and writes a step summary. It requests no repository write
permission in the mandatory path.
If you can create a fork from a second disposable account, use it to observe GitHub's real fork restrictions. Otherwise, complete the local claim/permission simulation supplied below. No secret, package, deployment, cloud account, self-hosted runner or paid feature is required.
2. Preflight and assumptions (verified 2026-09-10)
| Item | Mandatory value | Evidence |
|---|---|---|
| Repository | Disposable public repository only | Repository URL/name and visibility. |
| Runner | ubuntu-24.04 |
Run log runner/image metadata. |
| Checkout |
actions/checkout v7.0.1 at full SHA
3d3c42e5aac5ba805825da76410c181273ba90b1
|
Workflow text + checkout outputs/Git HEAD. |
| PR authority | contents: read only |
Workflow permission declaration and run log. |
| Secrets | None | No secret references in workflow. |
| Privileged lane |
pull_request_target, metadata-only,
permissions: {}
|
Workflow revision and no checkout/build step. |
| Fork approval | May be required for external contributors depending settings | Record pending/approved state if applicable. |
3. Create the tiny application and test
The repository content itself is intentionally boring; the security lesson is the authority boundary around it. A contributor can safely change the script and make tests pass or fail without ever receiving a base write token or secret.
mkdir -p scripts
cat > scripts/test.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
value="${1:-academy}"
[[ "$value" =~ ^[a-z0-9-]+$ ]]
printf 'tested_value=%s\n' "$value"
EOF
chmod +x scripts/test.sh
4. Lane A — restricted PR checks
This workflow is the only lane that executes contributor-controlled
repository code. The workflow deliberately does not request issue,
pull-request, package or deployment write authority.
persist-credentials: false prevents checkout from
leaving the job token configured in local Git credentials for later
scripts.
name: PR untrusted checks
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
test-untrusted:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Record identities
env:
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
shell: bash
run: |
printf 'run=%s attempt=%s event=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_EVENT_NAME"
printf 'base_repo=%s base_sha=%s\n' "$BASE_REPO" "$BASE_SHA"
printf 'head_repo=%s head_sha=%s\n' "$HEAD_REPO" "$HEAD_SHA"
printf 'checked_out=%s\n' "$(git rev-parse HEAD)"
- name: Execute repository test
run: ./scripts/test.sh academy
5. Open a fork PR and inspect before changing anything
From a second disposable account, fork the repository, edit
scripts/test.sh or a README, and open a PR. Before
approving any queued external-contributor run, inspect the
diff—especially .github/workflows/, build scripts,
package hooks and any file later interpreted as code. If approval is
required, record that state as governance evidence.
After the run starts, record the run ID, attempt, event name,
base/head repositories, base/head SHAs and checked-out SHA. The
expected fork behavior is a read-only GITHUB_TOKEN with
normal Actions secrets withheld. The workflow does not need to print
or probe a token to prove this; the declared permissions and
documented fork policy are the correct evidence sources.
6. Harmless injection demonstration: understand script generation
Do this demonstration locally, not by placing attack syntax in another person's repository. The string is synthetic and the only effect is printing a marker. First generate a shell file by interpolating untrusted text into shell source. Then pass the exact same text through an environment variable. The contrast shows why the vulnerability belongs to script generation, not merely to “bad characters.”
rm -rf /tmp/gha-pr-input-demo && mkdir /tmp/gha-pr-input-demo
cd /tmp/gha-pr-input-demo
UNTRUSTED='docs"; printf "INJECTED_AS_CODE\n"; #'
# Broken model: data is concatenated into generated shell source.
printf 'title="%s"\nprintf "done\n"\n' "$UNTRUSTED" > unsafe.sh
cat unsafe.sh
bash unsafe.sh
# Safe model: the script is fixed; input is runtime data.
cat > safe.sh <<'EOF'
set -euo pipefail
printf 'title_length=%s\n' "${#PR_TITLE}"
printf 'classification=%s\n' "$([[ "$PR_TITLE" == docs:* ]] && echo docs || echo other)"
EOF
PR_TITLE="$UNTRUSTED" bash safe.sh
Expected observation: the unsafe generated script
can interpret the marker as shell syntax; the safe script treats the
same bytes as the value of PR_TITLE. Do not put
secrets, tokens, network calls or destructive commands into this
demonstration.
7. Lane B — trusted workflow, metadata only
The second workflow reacts to the PR in base-repository context but does not fetch the PR tree, download PR-produced artifacts, run package installs, source files or invoke contributor-controlled scripts. Event strings enter fixed shell code only through environment variables. The mandatory version requests no write authority and writes only the job summary.
name: PR metadata gate
on:
pull_request_target:
types: [opened, reopened, synchronize]
permissions: {}
jobs:
classify:
runs-on: ubuntu-24.04
steps:
- name: Validate metadata as data
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
shell: bash
run: |
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]]
printf 'event=%s run=%s attempt=%s\n' "$GITHUB_EVENT_NAME" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_STEP_SUMMARY"
printf 'pr=%s head_repo=%s head_sha=%s\n' "$PR_NUMBER" "$HEAD_REPO" "$HEAD_SHA" >> "$GITHUB_STEP_SUMMARY"
printf 'title_length=%s\n' "${#PR_TITLE}" >> "$GITHUB_STEP_SUMMARY"
echo 'No fork checkout or fork artifact execution occurred.' >> "$GITHUB_STEP_SUMMARY"
8. Inspect the privileged lane for forbidden crossings
Search the trusted workflow for code-importing operations. The
expected result is no checkout, git fetch,
gh pr checkout, archive download, dependency
installation from the fork or execution of an artifact from Lane A.
Also confirm there is no
allow-unsafe-pr-checkout: true. A security review
should treat that input as an explicit exception requiring written
justification.
grep -nE 'actions/checkout|git fetch|gh pr checkout|allow-unsafe-pr-checkout|download-artifact|curl .*archive|npm (ci|install)|pip install' .github/workflows/pr-metadata.yml || true
9. Optional bounded metadata mutation
If you want to exercise a real privileged side effect, do it only in
the disposable repository. Pre-create an exact label such as
lab-reviewed-metadata; change the job permission to
issues: write; validate that the PR number is an
integer and that
github.event.pull_request.base.repo.full_name ==
github.repository; then call the GitHub API to add only that exact label. Record the
API response and remove the label afterward. Do not grant
contents: write, pass secrets or execute fork content.
The mandatory course completion path does not need this mutation. The design evidence—event, base workflow revision, requested permission, validation and absence of fork execution—is sufficient to teach the boundary without changing repository metadata.
10. Faithful simulation if a fork account is unavailable
Create a small JSON file with base_repo,
head_repo, base_sha,
head_sha, token_mode and
secrets_available. Mark the simulated fork as
token_mode=read-only and
secrets_available=false. Feed titles through the same
fixed-data validator. This simulation does not claim to prove GitHub
enforcement; it proves that your workflow logic has an explicit
model for the expected platform state.
11. Evidence packet
Save no tokens. Record: repository visibility; PR number; event; run ID/attempt; base/head repositories and SHAs; checked-out SHA; runner label/image metadata; declared permissions; whether fork approval occurred; whether secrets were intentionally absent; Lane B workflow SHA; and a statement that no fork code/artifact was executed in the privileged lane.
chapter22-lab-evidence/
preflight.md
pr-identities.txt
lane-a-run.txt
lane-b-run.txt
workflow-permissions.txt
injection-local-observation.txt
trust-boundary-review.md
cleanup.md
12. Cleanup and rollback
Close the disposable PR, delete the fork if created, remove the
optional lab label, delete the disposable repository when finished,
and remove /tmp/gha-pr-input-demo. If you changed
fork-approval or private-fork settings for observation, restore the
previous value and record that rollback. Never “clean up” by
deleting the first failing run before its evidence is captured.
13. Challenge: choose the layer, not the shortcut
A team needs to run contributor tests and then post one
authenticated comment. Decide between (a) one
pull_request_target job that checks out the fork, (b) a
low-privilege pull_request test plus a metadata-only
privileged workflow, or (c) a self-hosted runner with repository
secrets. State which workflow may execute fork code, which exact
data may cross the boundary, how you validate it and what evidence
proves the comment is bound to the intended upstream run.
14. Lesson summary
The lab demonstrates the desired shape: code execution stays in the
restricted PR lane; privileged logic stays on trusted workflow code
and accepts only validated metadata/data. The same separation
applies when the privileged follow-up is implemented with
workflow_run instead of
pull_request_target.
Knowledge check
Why does persist-credentials: false help the
untrusted test job?
It avoids leaving the checkout token configured in local Git credentials for later contributor-controlled commands. It does not replace least-privilege workflow permissions.
The safe metadata workflow uses permissions: {}.
Can it add a PR label?
No. The mandatory workflow is intentionally read-only. A real label mutation needs the narrow write permission and exact disposable-resource guards described in the optional exercise.
What does the local injection demonstration prove?
It proves that inserting untrusted bytes into generated shell source can turn data into syntax, while passing the same bytes through a quoted environment variable keeps them data.
Why is grepping for actions/checkout not a
complete privileged-workflow audit?
Untrusted code can enter through git commands, archive downloads, artifacts, package hooks or other actions. Review all paths that can import and execute contributor-controlled material.
A fork workflow cannot start until approved. Which state changed when a maintainer approves it?
The workflow becomes eligible to consume runner compute; the fork code itself does not become trusted and its authority boundary should remain restricted.
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.