Skip to content

fix(tasks): close structural masked links and the date-token arity hole in Slack blocks - #815

Merged
sroussey merged 2 commits into
claude/notify-merge-mainfrom
claude/optimistic-goldberg-d74u5n-slack-structural-links
Aug 16, 2026
Merged

fix(tasks): close structural masked links and the date-token arity hole in Slack blocks#815
sroussey merged 2 commits into
claude/notify-merge-mainfrom
claude/optimistic-goldberg-d74u5n-slack-structural-links

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Stacked fix onto PR #744 — this branch targets claude/notify-merge-main, not main, so it reviews and merges as a follow-up to that PR rather than duplicating its diff.

Two findings that must ship together: the second one's structural half (the rich_text date element's url) is implemented BY the first one's walk, and both rewrite the same two regexes and the same object branch.

[HIGH] Structural masked links in blocks bypassed delabeling

The masked-link remedy was purely lexical — MASKED_LINK over string leaves — but Block Kit expresses the same link structurally, and the deep walk rewrote only STRUCTURAL_BROADCAST_TYPES (broadcast, usergroup). So a structural masked link passed untouched in the default config (no allow_markup, no allow_mentions), while the README claimed blocks links are reduced to their bare URL:

{ "type": "button",
  "text": { "type": "plain_text", "text": "Deploy succeeded" },
  "url": "https://evil.example/pwn" }

There is no < anywhere in that payload for the lexical rule to match, and Slack renders the attacker-chosen label over the attacker-chosen destination — the exact phishing primitive escapeSlackText and stripSlackLinkLabels exist to remove.

The reduction now runs in the object branch of neutralizeSlackBroadcastsDeep, after the child map is built, gated on the same policy.stripLabels that drives stripSlackLinkLabels. Reduced form is identical in both halves: keep the destination, drop the label.

The rule is shape-driven, not type-enumerated, and that is the key decision. An overflow option carries url + text with no type discriminator at all, so a type set cannot cover it; and a shape rule cannot miss a new element type Slack adds. It stays narrow because only Block Kit objects that ARE links carry a plain url beside a label — image uses image_url/alt_text, video uses title_url — so a false positive has no shape to arrive in today.

shape result
string url + string text (rich_text link) text = url
string url + text: { text: string } (button, accessory, overflow option, any composition-object label) text.text = url
string url, no label field, type === "date" delete url
string url, no label field, anything else untouched (a bare link is already unmasked)

The url is never deleted from a button: a button with no url and no live action_id handler behind it is an availability change — the same argument the broadcast rewrite already makes for rewriting rather than deleting. The date element is the one case where deleting is right, because its label comes from format/fallback and a date renders fine unlinked.

[MEDIUM] The <!date^…> exemption re-admitted an attacker-chosen link + label

Both regexes matched a two-character prefix (<!date^, <!) rather than the token's safe arity. Slack's date token is <!date^ts^token_string^optional_link|fallback>token_string carries the rendered label and optional_link is a URL — so this survived verbatim in a blocks leaf with no flag set:

<!date^1700000000^Reset your password^https://evil.example|Nov 14>

The code comment justifying the exemption ("the | part is a FALLBACK, not a label masking a destination") is true only of the two-field form.

Decision: narrow the exemption to the safe arity. Rejected alternative: parse the token and strip only optional_link — that requires rebuilding the token inside a lexical pass, fails open on any malformed arity, and gains nothing, since a token with no link cannot phish.

Both regexes are now built from one shared source string so they cannot drift:

const SAFE_DATE_TOKEN = String.raw`!date\^\d+\^[^<>^|]*\|[^<>]*>`;
const BROADCAST_SIGIL = new RegExp(String.raw`<(?!${SAFE_DATE_TOKEN})!`, "g");
const MASKED_LINK = new RegExp(String.raw`<(?!${SAFE_DATE_TOKEN})([^<>|]*)\|[^<>]*>`, "g");

MASKED_LINK is narrowed too rather than relying on the broadcast pass having escaped everything else first — stripSlackLinkLabels is exported and callable alone, so each rule must be independently correct.

Verified against this branch's own cases: <!date^1700000000^{date_short}|Nov 14> stays exempt; the four-field attack fails the lookahead at the third ^ (which [^<>^|]* cannot consume) and is escaped; <!DATE^…> and a non-numeric timestamp still fail closed.

Two behavior tightenings

  1. A legitimately linked date token now needs allow_markup. The four-field form is markup carrying a destination, so it belongs on the markup rung like every other link.
  2. A date token whose FALLBACK contains a < no longer matches the exemption and is escaped whole. This breaks the existing test "a broadcast inside a date token's fallback is still escaped", which asserted posted).toContain("<!date^"). It is updated deliberately — it now asserts &lt;!date, &lt;!channel, and that no live <! survives. A token whose shape the matcher cannot fully verify fails closed. The regex was not weakened to keep the old assertion.

Tests

New structural masked links in blocks describe: a rich_text link element is reduced to its destination; a button accessory's label cannot mask its url (accessory.url and action_id unchanged); an overflow option's label cannot mask its url (no type key — pins the shape rule against a type-list regression); an image block is NOT treated as a link (narrowness guard); allow_markup keeps a structural link intact and allow_mentions leaves it verbatim (gate pins).

New date token arity describe: a date token carrying an optional link is escaped in blocks by default; the structural rich_text date element's optional link is stripped (url key absent, timestamp/format/fallback preserved); a date token with a non-numeric timestamp is escaped.

The existing query-string test also gained an explicit byte-identical assertion on the url leaf, so that invariant stays pinned under the new rewrite. The two-field date token in blocks, and the three other date-token cases, keep passing — which is the whole point of narrowing rather than deleting.

Schema description for blocks and packages/tasks/README.md updated to say the reduction covers both the <url|label> text form and the structural url+label form.

Verification

  • bun scripts/test.ts task vitest67 files passed, 1117 passed / 24 skipped. All 7 new/changed assertions were confirmed failing on the base branch first, each for the stated reason.
  • bun run format — no changes in packages/tasks or packages/test (it also reformats pre-existing unrelated files under examples/ and providers/; those were reverted and are not in this diff).

Generated by Claude Code

…le in Slack blocks

Block Kit says `<url|label>` a second way, structurally, and the delabeling
remedy was purely lexical: a button, a rich_text `link` element and an
overflow `option` each carry a `url` beside a label field with no `<` in the
payload at all, so an attacker-chosen label masked an attacker-chosen
destination in the DEFAULT config while the README claimed blocks links were
reduced to their bare URL.

The reduction now runs in the object branch of the deep walk, under the same
`policy.stripLabels` that drives the lexical one, and keys on SHAPE rather
than on a list of element types: an overflow `option` carries no `type`
discriminator, so a type set cannot reach it, and a shape rule covers whatever
url-bearing element Slack adds next. It is narrow because only Block Kit
objects that ARE links carry a plain `url` beside a label — `image` uses
`image_url`/`alt_text`, `video` uses `title_url`. The destination is kept and
the label overwritten, never the reverse: a button stripped of its `url` with
no live `action_id` behind it is an availability change, the same argument the
broadcast rewrite already makes for rewriting rather than deleting.

The date-token exemption had the same shape of hole. Slack's token is
`<!date^ts^token_string^optional_link|fallback>`, so the four-field form
carries both a label and a destination, and both regexes exempted it on a
two-character prefix. The exemption is now the safe ARITY, built from one
shared source string so the broadcast escape and the delabeler cannot drift,
and each rule is independently correct because `stripSlackLinkLabels` is
exported and callable alone. Its structural twin — the rich_text `date`
element's optional `url` — is the one case where deleting is right, since the
label comes from `format`/`fallback` and a date renders fine unlinked.

Two deliberate behavior tightenings: a legitimately linked date token now
needs `allow_markup`, and a date token whose fallback contains a `<` is a
shape the matcher cannot finish verifying, so it is escaped whole rather than
exempted with something unverified inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 60.56% 39096 / 64556
🔵 Statements 60.04% 41028 / 68328
🔵 Functions 61.12% 7568 / 12381
🔵 Branches 48.93% 20046 / 40967
File CoverageNo changed files found.
Generated in workflow #3180 for commit aa21bc3 by the Vitest Coverage Report Action

…-slack-structural-links

The base branch merged origin/main (main's streaming rework of FetchUrlTask,
plus the re-applied bounded Retry-After parser). No conflicts here.

Verified: bun scripts/test.ts task vitest -> 68 files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
@sroussey
sroussey merged commit 6d8c80d into claude/notify-merge-main Aug 16, 2026
10 of 11 checks passed
@sroussey
sroussey deleted the claude/optimistic-goldberg-d74u5n-slack-structural-links branch August 24, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants