DevOps & CI/CD Bug Report

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.

Ryan Pham Ryan Pham @ryan_bugs · May 07, 2025 · 6 min read · 5 Views
Banner: GitHub Actions pull_request_target workflow exposing secrets to code from forks

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_request runs from forks, not to pull_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_TOKEN permissions 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_target plus 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_target unless you only label, comment or triage.
  • Never check out and run PR code in a job that has secrets or write tokens.
  • Use pull_request for builds and workflow_run for privileged follow-ups.
  • Set default GITHUB_TOKEN permissions to read-only.
  • Lint workflows for dangerous patterns across all repositories.

References

Add a label

#github-actions #secrets #supply-chain #ci-security #pull-request-target
Written by
Ryan Pham @ryan_bugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.