GitHub Actions pull_request_target ran fork code with our secrets: closing the gap
An internal audit found a workflow that used pull_request_target to post preview links and also checked out the pull request's code and ran npm install. Any fork PR could have run code with our deploy token. We split it into an unprivileged build and a privileged comment step.
We run a public documentation site from a public repository and accept pull requests from the community. During a security review of our GitHub Actions workflows, we found this in preview.yml:
on:
pull_request_target:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build
- run: npx deploy-preview --token "${{ secrets.PREVIEW_DEPLOY_TOKEN }}"
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({ ...context.repo,
issue_number: context.issue.number, body: "Preview ready" })
The workflow had been added to make preview links work for fork pull requests, because the normal pull_request trigger does not give forks access to secrets.
Reproducing it
We did not attack our own repository with a real payload. Reading the trigger semantics was enough to confirm the exposure: a pull request from any fork would run npm ci and npm run build from the fork's code, in a job with PREVIEW_DEPLOY_TOKEN available and a GITHUB_TOKEN with write permissions on the base repository. A postinstall script or a modified build script in the fork would run with those credentials.
What we ruled out
- Exploitation in the past. We reviewed workflow run logs for the 14 months the workflow existed: 212 runs, all from known contributors or bots. The deploy service's access logs showed no unexpected deployments. We still rotated the token.
- Repository settings blocking it. "Require approval for first-time contributors" applies to
pull_requestruns from forks, not topull_request_target, which runs in the base repository's context.
Root cause
pull_request_target runs the workflow definition from the base branch, with the base repository's secrets and a read/write GITHUB_TOKEN. That is safe as long as the job does not run code from the pull request. Our workflow explicitly checked out the pull request head and executed its build scripts, combining untrusted code with privileged credentials.
What is happening under the hood
The two triggers differ in whose context they run:
| Trigger | Workflow file from | Secrets for fork PRs | GITHUB_TOKEN for fork PRs |
|---|---|---|---|
pull_request |
PR merge commit | No | Read-only |
pull_request_target |
Base branch | Yes | Read/write by default (subject to permissions) |
workflow_run |
Default branch | Yes | Read/write by default |
pull_request_target exists for tasks like labelling or commenting that need write access but do not need to run PR code. The danger appears when a step checks out the PR head (ref: github.event.pull_request.head.sha or head.ref) and then runs anything from it: install scripts, build tools, tests, even a linter config that can execute code.
Environment
| Component | Version / value |
|---|---|
| Repository | Public, accepts fork PRs |
| CI | GitHub Actions, GitHub-hosted runners |
| Build | Node.js 22, npm ci, static site generator |
| Secrets | PREVIEW_DEPLOY_TOKEN scoped to preview hosting |
| Default token permissions | Read/write (repository setting at the time) |
Minimal reproduction
The risky shape to search for:
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # untrusted code
- run: npm ci # executes it with secrets present
The fix
We split the workflow into two, so untrusted code never runs where secrets exist.
Workflow 1: build, unprivileged. Runs on pull_request, with read-only permissions and no secrets. It builds the site from the PR code and uploads the output as an artifact.
name: preview-build
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts && npm run build
- uses: actions/upload-artifact@v4
with:
name: site
path: dist/
retention-days: 3
Workflow 2: deploy and comment, privileged. Runs on workflow_run after the build completes. It never checks out or executes PR code; it downloads the built static files, treats them as data, and deploys them to an isolated preview domain.
name: preview-deploy
on:
workflow_run:
workflows: [preview-build]
types: [completed]
permissions:
contents: read
pull-requests: write
actions: read
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: site
path: site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: npx --yes deploy-preview@3.2.1 --dir site --token "$TOKEN"
env:
TOKEN: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}
Three supporting changes:
- Default
GITHUB_TOKENpermissions for the organisation set to read-only; workflows request what they need. - The preview token was rotated and re-scoped so it can only create previews on a separate domain, not touch production.
- The preview domain is isolated from our main domain (no shared cookies), because built pages from a fork can contain any JavaScript.
Alternatives and trade-offs
- Label-gated
pull_request_target. Run only after a maintainer adds a "safe to preview" label. Reduces risk, but a later push to the PR after labelling can still bring new code; you must re-check the head SHA. - No previews for fork PRs. Simplest and safest; we wanted previews for docs contributors.
- Third-party preview services with their own isolation. Valid; moves the trust boundary elsewhere.
How to test the fix
We opened a pull request from a test fork with a harmless postinstall that printed env | grep -c TOKEN. The build workflow printed 0, and its logs showed the read-only token. The deploy workflow posted the preview link without running any fork code.
Confirming in production
- Previews for fork PRs still work end to end.
- An org-wide search found two more repositories with
pull_request_targetplus a head checkout; both were fixed the same week. - The old preview token was revoked; hosting logs show only the new scoped token in use.
Regression guard
- A scheduled job runs a workflow linter (we use zizmor) across all repositories and flags dangerous triggers combined with untrusted checkouts.
- CODEOWNERS on
.github/workflows/requires a platform team review for workflow changes.
Security implications
This was a real vulnerability class, publicly known and actively exploited in other projects. The key rule: code from a pull request is untrusted, and untrusted code must run only where there are no secrets and no write tokens. Artifacts passed from an unprivileged workflow to a privileged one are also untrusted data: never execute them, never interpolate their contents into shell commands, and validate anything you parse from them.
Prevention checklist
- Avoid
pull_request_targetunless you only label, comment or triage. - Never check out and run PR code in a job that has secrets or write tokens.
- Use
pull_requestfor builds andworkflow_runfor privileged follow-ups. - Set default
GITHUB_TOKENpermissions to read-only. - Lint workflows for dangerous patterns across all repositories.
References
- GitHub, security hardening for GitHub Actions — https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
- GitHub Security Lab, preventing pwn requests — https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
- Events that trigger workflows: pull_request_target and workflow_run — https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
- zizmor, static analysis for GitHub Actions — https://github.com/zizmorcore/zizmor
Add a label
Embed this post
<iframe src="https://hibug.me/github-actions-pull-request-target-untrusted-checkout" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.