Repository navigation
Fix shell injection via PR title in Playground preview workflow - #5
Conversation
The "Replace version with PR info" step interpolated
${{ github.event.pull_request.title }} directly into a run: block. Because
GitHub Actions substitutes ${{ }} textually before the shell parses the
script, a PR title could break out of the string and run arbitrary commands
on the runner.
Pass the PR number and head SHA through env: instead of interpolating them
into the script, and drop the PR title entirely — the version is now stamped
as a fixed, safe 0.0.0-pr<number> string. Also add a fork guard so the job
only runs on same-repo PRs, which is where the contents: write push can
actually succeed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyu8Q9MCsyQuFcQd7sd2hK
Playwright test resultsDetails
Skipped testschromium › multiple-endpoints.spec.js › Multiple endpoints - EP_CATEGORIES overview endpoint › Editor endpoint selector › endpoint selector appears in Tab Visibility panel with multiple registered endpoints |
|
The one red check — Test PHP 8.3 / WP 6.8 — is a flaky Playground boot, not a real failure and unrelated to this change. The The WordPress Playground server never came up on that runner, so all three This PR only edits Generated by Claude Code |
The vulnerability
.github/workflows/pr-playground-preview.ymlinterpolated an attacker-controlled value directly into arun:block:GitHub Actions substitutes
${{ ... }}textually, before the shell parses the script, so the PR title breaks out of the quoted string and runs as shell on the runner. The PR number is numeric and safe; the title is not.Exact exploit
A PR opened (or edited — the workflow triggers on
edited, so just renaming an existing PR is enough) with the title:expands the line to:
— i.e. the assignment ends, the
curl … | shruns, and the trailing#comments out the dangling quote.Trigger, token scope, and severity — assessed, not inflated
on: pull_request(typesopened, synchronize, reopened, edited) — notpull_request_target.pull_request, a fork PR runs with a read-onlyGITHUB_TOKENand no access to secrets, regardless of thepermissions:block. That caps the blast radius — this is not the worst-case (write token + secrets) class of injection.actions/setup-nodewithcache: 'npm', so npm cache poisoning is a plausible escalation into later runs — including runs on the default branch that restore the same cache.pull_request_targetorissue_commentinstance of this pattern exists in the repo, so there is no write-token/secrets variant to call out.The fix
0.0.0-pr<number>string.env:(PR_NUMBER,HEAD_SHA) so they reach the shell as data, not as source text, and are always quoted ("${PR_NUMBER}").contents: writebranch push would hard-fail anyway; skipping is cleaner and, as a side effect, removes the external-attacker path to the injection step entirely.This mirrors the reviewed reference implementation now on
maininhumanmade/query-filter(.github/workflows/pr-playground-preview.yml).Remaining
${{ }}in the touched file — triageAll remaining interpolations are safe sources, and none sit inside a
run:block:…head.repo.full_name == github.repositoryif:github.event.pull_request.numberenv:github.event.pull_request.numberenv:github.event.pull_request.head.shaenv:github.repository,…pull_request.numberwith:blueprint stringsecrets.GITHUB_TOKENwith:Verification
python3 -c "import yaml; yaml.safe_load(...)"passes on the modified workflow.${{across the file and confirmed every remaining hit is a safe source outside anyrun:block (table above).query-filterreference.Out of scope — follow-ups (not fixed here)
pr-cleanup.ymlinterpolates${{ github.event.pull_request.number }}(numeric, safe) into arun:block and has no fork guard; itsgit push --deleteneedscontents: writeand would fail on fork PRs. Worth the sameenv:+ fork-guard treatment for consistency, but it's not an injection.release.yml(onmain) interpolates${{ github.event.release.tag_name }}intorun:blocks. Release tags are set by users with write access, so this is not the fork-PR vector — lower severity, same class. Note it is already being replaced wholesale by Switch release workflow to manual trigger with version input #4 (manualworkflow_dispatchwith a regex-validated version input).@v4,@v2) rather than by commit SHA; thequery-filterreference pins by SHA. Supply-chain hardening, optional.Generated by Claude Code