Chapter 22Lesson 02~190 minutes

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.

Hands-onHosted runnerSafe injection demoMetadata-onlyEvidence

Learning objectives

  • Build a disposable low-privilege pull_request workflow 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_target metadata-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.

Next lesson

Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Configuration, Design Patterns, and Trade-Offs

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

Knowledge check

Why does persist-credentials: false help the untrusted test job?

The safe metadata workflow uses permissions: {}. Can it add a PR label?

What does the local injection demonstration prove?

Why is grepping for actions/checkout not a complete privileged-workflow audit?

A fork workflow cannot start until approved. Which state changed when a maintainer approves it?

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.