-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Summary
The cloudflare-preview.yml workflow uses pull_request_target and checks out the pull request's head commit, then runs npm ci and npm run build on the attacker-controlled code, and deploys the resulting build to Cloudflare Pages. This allows any external attacker to deploy arbitrary JavaScript to a live Cloudflare Pages URL by opening a pull request.
Vulnerability
Workflow: .github/workflows/cloudflare-preview.yml
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
deploy-preview:
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm ci # executes attacker's package.json hooks
- run: npm run build # builds attacker's source code
- uses: cloudflare/wrangler-action@v3 # deploys attacker's build to Cloudflare
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}The workflow explicitly only runs for fork PRs (head.repo.full_name != github.repository), checks out untrusted PR code, runs the full build pipeline, and deploys the output to Cloudflare Pages using the repository's Cloudflare credentials.
Confirmed Impact
- Arbitrary code execution —
npm ciruns attacker-controlledpreinstall/postinstallhooks in the CI environment - Attacker-controlled JavaScript deployed to live Cloudflare Pages URL — the build output is deployed to
https://pr-XXXX.<project>.pages.devand a comment with the URL is posted on the PR - Cloudflare credentials exposed —
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDare used in the same workflow run as attacker code execution - GITHUB_TOKEN with write permissions —
pull-requests: writeanddeployments: write
Since this is a healthcare platform frontend, an attacker can inject malicious JavaScript into the deployed preview that targets anyone who visits the preview link (reviewers, QA team, stakeholders).
Suggested Fix
Replace pull_request_target with a two-workflow approach:
- A
pull_requestworkflow that builds the PR code without secrets (safe for forks) - A
workflow_runworkflow that deploys the build artifact only after the first workflow succeeds
# Workflow 1: build.yml (safe, no secrets)
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: build/
# Workflow 2: deploy-preview.yml (privileged, uses secrets)
on:
workflow_run:
workflows: ["build"]
types: [completed]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# ...This ensures attacker code never runs in the same context as Cloudflare credentials.
References
- https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
- https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
Credit
Discovered by Wilson Cyber Research (@sourcecodereviewer). We request credit in any advisory or fix commit.
Timeline
- 2026-03-20: Discovered, confirmed via PoC (attacker JS deployed to live Cloudflare URL), all test PRs closed, report filed
Metadata
Metadata
Assignees
Labels
Type
Projects
Status