Skip to content
L-04Journal / ArticleEL +0.00 m
Backend Security11 min read

GitHub Actions in Production, Part 7: Twelve Lines and a Script Injection

The smallest workflow I have written posts deploy notifications to Discord, and it contains a real script injection. Why ${{ }} in a run block is templating rather than variable expansion, why merge commit messages are untrusted input, and why the file nobody reviewed had the bug.

By Mohammed Mostafa · Published

GitHub ActionsApplication SecurityCI/CDShellDevSecOpsDiscordSupply Chain Security

The smallest workflow in this library posts a message to Discord whenever someone pushes to live. Who pushed, which branch, what the commit said, with the person's GitHub avatar attached. It took about ten minutes to write.

It has answered "when did this change?" more times than any dashboard I've ever built.

It also contains a real security bug. The GitHub Actions script injection pattern, where untrusted event data gets interpolated straight into a shell command. It's the most common vulnerability class in Actions, it's called out in GitHub's own hardening docs, and I wrote it anyway, in twelve lines, without noticing.

Introduction#

That combination is why this one gets a full article. The value is real and I'd write it again tomorrow. The bug is real too, and it's about one character of syntax away from not existing.

Why this workflow exists#

Automating deployment had a side effect I didn't anticipate: it made deploys invisible.

When deploying meant somebody SSHing into a server, the deploy was a social event. Someone said "pushing the fix now" in chat. Everyone knew. Once the pipeline does it automatically on merge, code reaches production with no human announcement at all, and the team quietly loses a signal it had been depending on without ever noticing it was a signal.

The failure mode this creates is specific. Something breaks. Someone asks whether anything changed. Nobody knows. You open the Actions tab, cross-reference run timestamps against the error spike, find the commit, read the diff. Ten minutes minimum, every time, and those ten minutes land at the very front of an incident where latency is most expensive.

A chat message collapses that into a scroll. "When did this change?" stops being an investigation and becomes a lookup.

Two decisions came out of framing it that way.

It targets a team chat channel, not a monitoring system. Nothing pages anyone, there's no threshold, no acknowledgement. It's ambient awareness in a place people already have open. Every time I've seen someone try to make a notification like this into proper alerting, it turned into noise and then got muted.

And it posts as the developer rather than as a bot. The webhook overrides username and avatar_url with the pushing actor's GitHub identity, so the channel shows a face instead of a generic integration icon. That was deliberate. It makes deploys feel attributable, and more practically, you recognise a colleague's face far faster than you read a username when you're scrolling back through a channel.

Architecture#

snippettext
  push: live
      │
      ▼
  ubuntu-latest
      │  (no checkout — nothing to check out)
      ▼
  curl -X POST  ──────►  Discord webhook endpoint
      │                        │
      │  JSON body:            ▼
      │   avatar_url      ┌──────────────┐
      │   username        │  #deploys    │
      │   content ────────│  channel     │
      │                   └──────────────┘

One job, one step, nothing else.

The property worth pointing at is the decoupling. This subscribes to the same push event as the deploy workflows rather than being a step inside them. If the webhook gets rotated, or rate-limited, or Discord goes down, the deploy is completely unaffected. Notification failures can't break deployment.

That's right, and it comes with a cost I'll get to: because it's triggered by the push rather than by the deploy outcome, it announces that a deploy started. It can't tell you whether it worked.

Step-by-step explanation#

There's one step, so here's the whole thing:

snippetyaml
- run: |
    curl -H "Content-Type: application/json" \
         -X POST \
         -d "{
              \"avatar_url\": \" https://github.com/${{ github.actor }}.png \",
              \"username\": \" ${{ github.actor }} \",
              \"content\": \" Admin: @elrefai99 \nServer: **0Gosha Server*** \nNew push in branch: **${{ github.ref_name }}** \nCommit: **${{ github.event.head_commit.message }}**\"
            }" \
         ${{ secrets.DISCORD_WEBHOOK_0GOSHA }}

https://github.com/<user>.png is a nice trick that I use constantly now. GitHub serves any user's avatar at that URL, with ?size=64 if you want it smaller. No API call, no token, no storage, and Discord fetches it directly.

The webhook URL is stored as a secret, correctly. A Discord webhook URL is the credential. Anyone holding it can post anything to that channel as anyone. It should be quoted in the command for safety, but keeping it out of the YAML is the important part.

Then there are the string problems, which are minor and visible in every message this thing has ever sent.

The avatar URL has leading and trailing spaces inside the string: \" https://github.com/… .png \". Discord may reject the malformed URL and fall back to a default avatar, which defeats the entire point of the avatar trick. The username has the same padding, so the display name renders with visible whitespace. **0Gosha Server*** has three closing asterisks against two opening ones, so it renders with a stray * hanging off the end. And Admin: @elrefai99 is a plain string; Discord mentions need <@USER_ID> with a numeric snowflake, so @username renders as literal text and pings precisely nobody.

None of that breaks anything. All of it has been in every message for months, which is its own small lesson about output nobody re-reads after the first test.

The injection#

Here's the line that matters:

snippetbash
\"content\": \"… Commit: **${{ github.event.head_commit.message }}**\"

${{ … }} inside a run: block is not shell variable expansion. The runner does textual substitution into the script before the shell parses it. So the commit message doesn't arrive as data. It becomes part of the source code of the script.

Which means the shell parses whatever the commit message happens to contain.

The mild version: a commit message with a double quote in it terminates the JSON string early and the request goes out malformed. The notification silently fails or posts garbage.

The serious version: a commit message containing shell command substitution syntax gets executed on the runner. And the runner, at that moment, has secrets.DISCORD_WEBHOOK_0GOSHA in its environment and curl sitting right there. Anything the job can reach, injected code can reach.

The obvious objection is that only people with write access can push to live, so an attacker would need commit rights already. That's true, and it does limit the risk here substantially. But it doesn't eliminate it, and the reason is easy to miss:

On a merge commit, head_commit.message contains text written by whoever opened the pull request. Merge a fork PR and the resulting commit message includes the PR title and the source branch name, both chosen entirely by someone with no access to your repository. The push to live is performed by a trusted maintainer. The content of the message is not trusted. That's exactly the boundary GitHub's hardening guide warns about, and github.event.head_commit.message is named in its list of untrusted inputs alongside PR titles, branch names, and issue bodies.

Branch names deserve their own mention, because they flow into merge commit messages, they're attacker-chosen on fork PRs, and git permits a surprising range of characters in them.

The fix is to stop letting the value be code. Pass it through the environment so the runner sets a variable and the shell reads it as data:

snippetyaml
- env:
    COMMIT_MSG: ${{ github.event.head_commit.message }}
    ACTOR: ${{ github.actor }}
    BRANCH: ${{ github.ref_name }}
    WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_0GOSHA }}
  run: |
    jq -n \
      --arg actor "$ACTOR" \
      --arg branch "$BRANCH" \
      --arg msg "$COMMIT_MSG" \
      '{
        username: $actor,
        avatar_url: "https://github.com/\($actor).png",
        content: "**\($branch)** — \($msg)"
      }' \
    | curl -sS -X POST -H "Content-Type: application/json" -d @- "$WEBHOOK"

Two independent protections there. env: keeps the value out of the script text entirely, so the shell sees $COMMIT_MSG, a variable reference, and quoting it prevents word splitting. And jq -n --arg builds the JSON with correct escaping for quotes, newlines and backslashes, so malformed payloads become impossible rather than just unlikely. jq is preinstalled on GitHub-hosted runners.

That's the whole fix. It isn't more code. It's arguably cleaner code, and it closes the hole completely.

Interesting implementation details#

No checkout, no setup, no actions. A workflow that only makes an HTTP call needs none of it, and adding actions/checkout out of habit would roughly double the runtime for nothing. Worth noticing when the boilerplate genuinely isn't required.

The identity spoofing is a UX feature and a security consideration at the same time. Discord webhooks let the caller override the display name and avatar per message, which is what makes the channel scannable. It's also exactly why a leaked webhook URL is bad: whoever has it can impersonate anyone in that channel.

The \n handling works, though more by luck than design. \n inside a double-quoted shell string passed to -d gets sent literally, and Discord's JSON parser interprets \n in a string value as a newline. Correct outcome, not a correct reason.

And it's cheap enough that nobody's ever been tempted to remove it. A few seconds of runner time per push. This is the good version of what I said in the S3 article about cheap things surviving. Cheap wrong things last too long, but cheap right things also last, and low cost is exactly why this one never came up in a cleanup.

Common mistakes#

Interpolating ${{ }} event data into run: blocks. That's the vulnerability. github.event.head_commit.message, github.event.pull_request.title, github.head_ref, issue bodies, review comments — all attacker-influenceable, all routinely pasted straight into shell.

Hand-building JSON in shell, where any user-supplied string with a quote or backslash or newline in it breaks the payload.

Notifying on the trigger instead of the outcome. This announces that a push happened and says nothing about whether the deploy succeeded, which is the thing people actually want to know.

Treating webhook URLs as configuration when they're credentials.

Assuming write access bounds the threat, when merge commit messages carry text authored by untrusted contributors.

And never re-reading your own output. Four separate formatting defects shipped in every message this system has sent, because nobody looked at the rendered result after the first successful test.

Lessons learned#

Notification is infrastructure, not decoration. Highest return per line of anything I've written. Deploy visibility is a real operational capability and the fact that it's trivial to build makes it easy to undervalue, right up until the week you don't have it.

Automation removes social signals and you have to replace them deliberately. The old manual deploy broadcast itself as a side effect of being manual. Automating it silently deleted that broadcast, and nobody could articulate what was missing for a while. Any time you automate a human process, it's worth asking what implicit communication just disappeared with it.

${{ }} in run: is templating, not variable expansion. Internalising that one distinction prevents an entire vulnerability class, and the rule is mechanical enough to apply without thinking: event data goes through env:, never into script text.

Small workflows get no review. This is twelve lines that nobody read carefully, me included, because it's "just a curl." The Docker and ECS workflows got scrutiny proportional to how complicated they looked. The injection bug is sitting in the file everyone assumed wasn't worth reviewing, and that's not a coincidence. Review effort should track what a workflow can touch, not how complex it appears.

And look at what you shipped. Padded strings, stray asterisks, a mention that mentions nobody, all visible on the very first render.

Production considerations#

Discord webhook URLs don't expire. If one leaks in a log, a screenshot, or a fork, anyone can post to that channel as anyone, indefinitely, until a human manually regenerates it. It belongs on the same rotation schedule as any other credential and it almost certainly isn't on one.

Rate limits are worth knowing about. Discord limits webhooks to roughly 5 requests per 2 seconds, with per-channel limits behind that. A burst of pushes gets 429s, and this workflow doesn't check the response at all. curl without -f exits 0 on an HTTP error, so a dropped notification looks exactly like a successful one. For ambient signalling that's arguably acceptable, but it should be a choice rather than an assumption, and mine was an assumption.

Information disclosure is the one I'd think hardest about before copying this pattern. Commit messages go to a chat channel. If that channel has broader membership than the repository, contractors, a community server, people who joined for something unrelated, you're publishing commit messages to that audience. Commit messages reference internal systems, customer names, and security fixes all the time.

There's no failure path anywhere. Nothing notifies when a deploy fails. That's the biggest functional gap here: the system is chattier about routine success than about failure, which is backwards for anything operational.

And it's coupled to one channel. The channel name is baked into a secret name and the message body hardcodes a server name, which is fine for one project and awkward the moment you reuse the template, which is supposedly the entire premise of the repository it lives in.

Improvements#

Fix the injection with env: plus jq. Non-negotiable, and it's what makes everything below worth doing.

Then the structural change: notify on the deploy outcome instead of the push.

snippetyaml
on:
  workflow_run:
    workflows: ["Deploy to AWS", "Deploy to EC2"]
    types: [completed]

Now the message can carry the actual result, and ${{ github.event.workflow_run.conclusion }} lets you colour it green or red. That's what turns this from "someone pushed" into "production changed, and here's how it went."

Always notify on failure, and consider throttling success. Failures are the high-value signal. A team receiving twenty green messages a day stops reading the channel, and then misses the red one, which is the worst possible outcome.

Use Discord embeds instead of plain content. Structured fields, a colour bar keyed to success or failure, a clickable link to the run and the commit. Scannable at a glance and it's not more code than the current string concatenation.

Add a link to the run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. One line, and every notification becomes a starting point for triage instead of a dead end.

Fix the formatting. Strip the padding, balance the asterisks, use <@USER_ID> if the mention is meant to actually ping someone.

Check the response with curl -fsS so an HTTP error fails the step, plus continue-on-error: true on the job so a notification failure is visible without being mistaken for a deployment failure.

Truncate long commit messages, since Discord's content field caps at 2000 characters and a long message body silently fails the request.

And parameterise it for reuse. As a workflow_call workflow taking status, environment, and a secrets.WEBHOOK_URL, one notification implementation serves every repository, which is what this template library was supposed to deliver in the first place.

That's the series. Seven workflows: a compile gate in the wrong place, a release automation that's nearly perfect, two container pipelines that don't agree with each other, a VM deploy with a shell trap in it, a snapshot job with an unstated threat model, and twelve lines of chat notification that turned out to be carrying the most important lesson of the lot.

What is GitHub Actions script injection?
The runner substitutes ${{ }} expressions into a run block as text before the shell parses it, so event data becomes part of the script's source rather than arriving as data. A commit message or PR title containing shell command substitution then executes on the runner alongside your secrets.
Is head_commit.message untrusted if only maintainers can push?
Yes. On a merge commit the message contains the pull request title and source branch name, both chosen by whoever opened the PR, including fork contributors with no repository access. The maintainer performs the push; the text inside the message is still attacker-controlled.
How do you safely use event data in a GitHub Actions run block?
Pass it through an env: mapping so the runner sets a variable and the shell reads it as data, then quote the reference. For JSON payloads, build the body with jq --arg rather than string concatenation so quotes, newlines and backslashes are escaped correctly.
Should deploy notifications trigger on push or on workflow completion?
On completion. A push trigger only announces that a deploy started and can never report whether it worked. Using workflow_run with types: [completed] lets the message carry the real conclusion, which is the signal people actually want when something breaks.
Is a Discord webhook URL a secret?
Yes. The URL is the credential: anyone holding it can post anything to that channel as anyone, since webhooks let the caller override username and avatar per message. They never expire, so a leaked URL stays usable until someone manually regenerates it.