From 8e6d1a9a534109f7658a9b899bded4b938608d52 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:52:35 -0700 Subject: [PATCH] fix(slack): stop double-decoding HTML entities when escaping message text format_message unescapes already-escaped input before re-escaping, so that pre-escaped text doesn't get double-escaped. That unescape was three sequential str.replace calls, which re-scan each other's output: "&lt;" --(& -> &)--> "<" --(< -> <)--> "<" The & produced by the first replace pairs with the following "lt;" and decodes a second time. "&lt;" is the wire form of the literal text "<", so the text is silently destroyed: Slack receives "<" and renders "<". Anyone writing about HTML or markup ("&lt;b&gt;" -> "") loses their literal text, with no error. re.sub scans left-to-right and never re-scans its own replacements, so a single pass fixes it. The escape pass on the next line is left untouched -- it is correctly ordered (& first, so the &s it inserts aren't re-escaped). Only the double-decode cases change; every other input is byte-identical before and after. This is the same round-trip invariant the neighbouring test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert, extended to the case they miss. Affects the plain mrkdwn path (send, edit_message) and Block Kit sections, which route section text through format_message. --- plugins/platforms/slack/adapter.py | 9 ++++++++- tests/gateway/test_slack.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index a13c8d2c54b..67b30864812 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3256,7 +3256,14 @@ class SlackAdapter(BasePlatformAdapter): # 6) Escape Slack control characters in remaining plain text. # Unescape first so already-escaped input doesn't get double-escaped. - text = text.replace("&", "&").replace("<", "<").replace(">", ">") + # Single pass: sequential str.replace would re-scan its own output, so + # the & from "&" could pair with a following "lt;" and decode twice + # ("&lt;" → "<" → "<"), destroying literal entity text. + text = re.sub( + r"&(amp|lt|gt);", + lambda m: {"amp": "&", "lt": "<", "gt": ">"}[m.group(1)], + text, + ) text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 7) Convert headers (## Title) → *Title* (bold) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index edcfa79102a..f601cf63e25 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -4068,6 +4068,16 @@ class TestFormatMessage: """Already-escaped > in plain text must not become &gt;.""" assert adapter.format_message("5 > 3") == "5 > 3" + def test_escaped_entity_text_not_double_decoded(self, adapter): + """&lt; is the wire form of the literal text < — it must survive. + + The unescape pass must not re-scan its own output: decoding & to & + first must not let the resulting & combine with a following lt; into a + second decode, or the literal text is silently destroyed. + """ + assert adapter.format_message("&lt;") == "&lt;" + assert adapter.format_message("&gt;") == "&gt;" + def test_mixed_raw_and_escaped_entities(self, adapter): """Raw & and pre-escaped & coexist correctly.""" result = adapter.format_message("AT&T and & entity")