Escape untrusted issue title/author in the Slack notification workflow#375
Merged
Conversation
qing-ant
force-pushed
the
fix/slack-issue-notification-injection
branch
from
July 13, 2026 16:54
e643292 to
cb4b6af
Compare
qing-ant
marked this pull request as ready for review
July 13, 2026 20:57
Issue titles and author logins on a public repo are attacker-controlled. The
workflow interpolated them with ${{ }} straight into a hand-written JSON payload
string, so a crafted title could break out of the JSON literal and rewrite the
Slack message.
Pass every untrusted value in via env: only, and build the payload with jq. The
mrkdwn escape lives inside the jq program rather than in a bash
${var//pat/repl} substitution: the bash form is only correct while
patsub_replacement is off (bash 5.2 turns it ON by default, which rewrites
`<` -> `<` as `<lt;`), so its correctness would depend on a shopt line a
future edit could silently drop. jq has no such option. A self-test runs the
same esc definition the payload uses and hard-fails the job rather than posting
degraded output. jq -c also guarantees the payload is single-line, so the
key=value $GITHUB_OUTPUT framing is safe by construction.
No step needs GITHUB_TOKEN, so the job drops to permissions: {}.
qing-ant
force-pushed
the
fix/slack-issue-notification-injection
branch
from
July 13, 2026 21:08
cb4b6af to
848afd0
Compare
qing-ant
marked this pull request as draft
July 13, 2026 22:51
qing-ant
marked this pull request as ready for review
July 13, 2026 22:53
qing-ant
enabled auto-merge (squash)
July 13, 2026 22:54
ashwin-ant
approved these changes
Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
.github/workflows/slack-issue-notification.ymlruns onissues: [opened]and builds the Slackchat.postMessagepayload by splicing two attacker-controlled values directly into a hand-written JSON string literal:${{ github.event.issue.title }}${{ github.event.issue.user.login }}Anyone can open an issue on a public repo, so both are untrusted free text. Two consequences:
<,>and&as control characters, and the title is placed inside a<url|label>link construct. Nothing escapes them, so title text is interpreted as markup rather than shown as text, and can restructure the message a reader sees rather than appearing as its content.This is the standard untrusted-input hygiene problem for workflows that build a payload out of event data. Reported as HackerOne #3857924.
Ordering note
The notifier appears to have never successfully delivered, so this is currently latent rather than actively exploited. That is the only reason it has not been exercised, and it is what makes the ordering matter:
Escaping must land before Slack delivery is repaired. If delivery is fixed first, the unescaped payload goes live at that moment.
Fix
Build Slack payloadstep runs first. Untrusted values reach the shell only viaenv:— never through${{ }}interpolation inside arun:body or inside a payload string.jq, and the mrkdwn escape (&→&, then<→<, then>→>, then control chars → space) lives inside the jq program. The payload is emitted as a single step output and handed to the Slack action verbatim.escdefinition over<&>and requires<&>. If it ever fails, the step emits a::error::annotation and exits non-zero — the job fails instead of posting degraded output. There is one definition ofesc, shared by the self-test and the payload, so the two cannot drift.permissions: {}— no step usesGITHUB_TOKEN(no checkout, nogh/API calls); the only credential is the Slack bot token, passed as a secret. So the job needs no scopes.The message's user-visible shape is unchanged: issue link, number, title, author. This is a hardening change, not a redesign.
Why the escape moved out of bash
An earlier revision of this fix escaped with bash parameter substitution (
${var//pat/repl}) plus ashopt -u patsub_replacementguard.That guard is load-bearing. bash 5.2 — the default on
ubuntu-latest— enablespatsub_replacement, under which an unescaped&in the replacement half of${var//pat/repl}expands to the matched text. The substitutions that break are the ones whose replacement starts with&:${s//</<}degrades to<lt;and${s//>/>}degrades to>gt;, leaving the raw</>in the output — exactly the characters the escape exists to remove. (&→&survives unguarded only by coincidence: the text&matches is&, so&re-expands to itself. Testing the guard with an ampersand therefore "passes" and proves nothing.)Working code whose correctness depends on a
shoptline three lines away is a trap: a future edit deletes the line as noise and the escape degrades with no signal. jq has no such option, so doing the escaping there removes the dependency entirely rather than documenting it. Theshoptline and the bash escape function are gone.Two details in the jq program are deliberate:
&must be escaped first, or the&in</>gets double-escaped.[[:cntrl:]], not an escaped codepoint range. jq's Oniguruma regex mangles the\u-range form — it matches ordinary letters and eats them ("Bug in <Foo> component"→ all spaces). Verified on the runner's jq;[[:cntrl:]]is correct.jq -cguarantees the payload contains no literal newline, so thekey=valueframing of$GITHUB_OUTPUTis safe by construction rather than by a separate stripping step.Both SDK repos are now identical
A review found the Python and TypeScript patches had drifted (different control-char stripping,
set -euo pipefailin one only, URL escaped in one only). The two workflow files are now byte-identical, and produce byte-identical payloads for every case below.Verification
Defensive testing only; benign inputs, no attack payload constructed, nothing posted to the Slack channel.
The real workflow file was run under
actoncatthehacker/ubuntu:act-24.04(bash 5.2.21,patsub_replacementon by default; jq 1.6), with only the Slack-posting step'suses:swapped for a local action that dumps thewith: payload:value byte-identically. Syntheticissues.openedevents:blocks[0].text.textPlain ASCII title…|#42 Plain ASCII title>Client says "connection reset" on retry…|#42 Client says "connection reset" on retry>Tips & tricks…|#42 Tips & tricks>Bug in <Foo> component…|#42 Bug in <Foo> component>Windows path C:\Users\dev fails…|#42 Windows path C:\Users\dev fails>Newline⏎embedded title…|#42 Newline embedded title>Fix <A> & <B>…|#42 Fix <A> & <B>>No case produced
<lt;or>gt;. The only unescaped angle brackets in any output are the two link delimiters the workflow writes itself.Option-independence proof. The escape block was run on bash 5.2.21 with
shopt -u patsub_replacementand again withshopt -s patsub_replacementdeliberately ON. Output was identical for all seven cases — which is the whole point of moving the escape into jq: there is no longer a shell option that can change the result.Self-test fires. Deliberately corrupting the
escdefinition (<→<) makes the step fail:The Slack step never runs — no payload is dumped.
Also:
actionlintclean on both repos (it shellchecks therun:block too), and the YAML parses.