fix(tasks): escape Slack markup by default, closing link/label injection - #805
Merged
sroussey merged 1 commit intoAug 15, 2026
Conversation
`BROADCAST_SIGIL` escapes only `<!`, so `<https://evil.example|Deploy succeeded>` passed both the lexical escape and the deep walk untouched, and `link_names: false` governs bare `@name` text rather than control sequences. Slack renders the LABEL in place of the URL, so a notification assembled from a fetch result or a model summary could display a phishing destination as a status line. Two tests pinned that behaviour as a guarantee; under this task's own threat model the guarantee IS the vulnerability. `text` and `blocks` need DIFFERENT policies. Full `&`/`<`/`>` escaping cannot be applied by `neutralizeSlackBroadcastsDeep` to every string leaf — that walk is shape-agnostic by design and reaches `url`, `image_url`, `value` and `action_id` leaves, where escaping `&` corrupts every query string (`?a=1&b=2` → `?a=1&b=2`). `<!` is safe to escape everywhere precisely because it has no legitimate occurrence there; `&` does. So `text` gets `escapeSlackText` (`&` FIRST, or the next two passes double-escape the ampersands they introduce) and `blocks` gets `stripSlackLinkLabels`, `<(?!!)([^<>|]*)\|[^<>]*>` → `<$1>`. `(?!!)` exempts the `<!…>` control family — the date token's `|Nov 14` is a fallback, not a masking label — and `[^<>|]` keeps a match inside one sequence. The deep walk takes an explicit `{ stripLabels }` policy at both call sites. Within a leaf the broadcast escape runs first: reversed, `<https://x/|<!channel>>` survives, since the label's inner `<` blocks the delabeler. The new `allow_markup` port defaults to FALSE. The threat model is piped and model-generated content, an opt-in control nobody sets protects nobody, every other control here fails closed, and these tasks do not exist on main — so default-on has zero released blast radius and this is the only moment the safe default is free. The escaped message stays readable; only clickability is lost. It is a separate port from `allow_mentions` because wanting a build link is orthogonal to wanting `@channel` to ping four hundred people; `allow_mentions` implies it, so the ladder has no dead rung. `username`/`icon_emoji` stay on the broadcast escape only: whether Slack un-escapes entities in a display name is unverified, so escaping there risks a literal `&` in a bot name for no attested gain. Co-Authored-By: Claude <noreply@anthropic.com>
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.
Follow-up to #744, targeting the same base branch. Branched independently of #800, which is the other half of the same review.
M3 — Slack link/label injection is live
SlackNotifyTask.ts'sBROADCAST_SIGIL = /<!(?!date\^)/gescapes only<!. So<https://evil.example|Deploy succeeded>passes both the lexical escape and the deep walk untouched, andlink_names: falsegoverns bare@nametext rather than control sequences. Slack renders the LABEL in place of the URL, so a notification assembled from a fetch result or a model summary — the exact threat model every other control on this task exists for — can display a phishing destination as a status line.Two existing tests pinned that behaviour as a guarantee ("a caller-supplied
<url|label>reaches Slack verbatim"). Under this task's own threat model that guarantee is the vulnerability, since the label is attacker-chosen. Both halves of each assertion survive, moved behind the new flag.textandblocksneed different policiesFull
&/</>escaping cannot be applied byneutralizeSlackBroadcastsDeepto every string leaf. That walk is shape-agnostic by design and reachesurl,image_url,valueandaction_idleaves as readily astextones — escaping&there corrupts every query string it touches (?a=1&b=2→?a=1&b=2).<!is side-effect-free to escape everywhere precisely because it has no legitimate occurrence in those fields;&does.So:
text→escapeSlackText—&FIRST, then<, then>. Order is load-bearing: escaping the brackets first would have the&pass re-escape the ampersands they just introduced, and<c>would arrive as&lt;c&gt;.blocks→stripSlackLinkLabels—/<(?!!)([^<>|]*)\|[^<>]*>/g→<$1>.(?!!)exempts the<!…>control family (the date token is<!date^…|Nov 14>, whose|part is a fallback, not a masking label);[^<>|]keeps a match inside one sequence rather than spanning two independent links.neutralizeSlackBroadcastsDeeptakes a{ readonly stripLabels: boolean }policy, required and explicit at both call sites rather than defaulted.<https://x/|<!channel>>survives — the label's inner<preventsMASKED_LINKfrom matching, so nothing is stripped and the masked link is merely escaped and left live. There is a test for exactly this.Why the new port defaults to
falsemain, so default-on has zero released blast radius — this is the only moment the safe default is free.Why a new port rather than reusing
allow_mentionsWanting a build link in a deploy notification is orthogonal to wanting
@channelto ping four hundred people.allow_mentionsimpliesallow_markup, so the ladder has no dead rung (live mentions with dead links is not a state anyone asks for) — there is a test for that too.The comment at
SlackNotifyTask.tsargued against a second port on the grounds it would have "no behavior behind it". That is no longer true, and the comment is updated to say so.username/icon_emojistay on the broadcast escape only: whether Slack un-escapes entities in a display name is unverified, so escaping there risks a literal&in a bot name for no attested gain — and neither field renders a link, so masked-link injection has nowhere to land. Commented in place, mirroring the existing "defence in depth, NOT a closed bypass" note.Test changes
The two pinned tests are replaced, not deleted, each becoming a default-behaviour case plus an
allow_markupcase, with the justification in the comment::986→"Slack escapes markup in text by default"("<https://x/|y> pinged <@U1>") +"allow_markup keeps links and single-user mentions intact".:1087→"Slack strips masked link labels inside blocks by default"("<https://x/> <@U1>") + anallow_markuptwin, commented that a blocks leaf cannot be entity-escaped because the same walk visitsurlleaves.The four date-token tests that use
textaddallow_markup: trueunder one shared comment — a date token is markup, and nothing about the(?!!)exemption itself changed. The date-token-in-blockstest needs no flag and is the load-bearing assertion for(?!!):blocksis delabeled by default, so the delabeler is live there. An explicitexpect(...).toContain("|Nov 14")is added so the exemption cannot regress silently behind the whole-leaftoBe."Slack neutralizes channel-wide broadcasts by default"now asserts<!channel>rather than<!channel>— intextthe total escape subsumes the narrow<!one, so the closing bracket is escaped too. The broadcast is just as dead; the narrow form is still whatblocksandallow_markupproduce and is asserted as such elsewhere. Commented in place.New cases: the literal M3 repro escaped by default in
text; the same string delabeled inside ablocksleaf;escapeSlackTextescapes&before</>("a & b <c>"→"a & b <c>", no&amp;); ablocksurlleaf containing?a=1&b=2is untouched — the regression the whole split exists to avoid;allow_mentions: truestill implies markup;allow_markupdoes not re-enable broadcasts; and the escape/delabel ordering case above.README.md:allow_mentionsrewritten (it governstext,blocks,username,icon_emojiand impliesallow_markup),allow_markupadded, the Features bullet split into a broadcasts half and a markup half, and the residual stated explicitly — withallow_markup: truea masked link intextis live again, andblockslabels are stripped rather than escaped because the walk cannot tell atextleaf from aurlleaf.Lower-churn alternative, if default-on escaping of
textis rejectedApply
stripSlackLinkLabelsuniformly totextandblocksand skipescapeSlackTextentirely. Date tokens then keep working with no test churn at all, and masked links still die — but injected<@U123>pings and bare<https://evil.example>links survive intextas well. The default-on version is what is implemented here; say the word and it is a small edit.Verification
(baseline on this branch's merge-base:
1084 passed | 24 skipped— +9 net, after the two pinned tests were each split in two)There is no root
lintscript;formatiseslint --fix && prettier --check --write, run here in check-only form.Generated by Claude Code