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:

    "&amp;lt;"  --(&amp; -> &)-->  "&lt;"  --(&lt; -> <)-->  "<"

The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&amp;lt;" is the wire form of the literal text
"&lt;", so the text is silently destroyed: Slack receives "&lt;" and renders
"<". Anyone writing about HTML or markup ("&amp;lt;b&amp;gt;" -> "<b>")
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.
This commit is contained in:
briandevans 2026-07-14 20:52:35 -07:00 committed by Teknium
parent 50a6dc7efc
commit 8e6d1a9a53
2 changed files with 18 additions and 1 deletions

View file

@ -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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
# Single pass: sequential str.replace would re-scan its own output, so
# the & from "&amp;" could pair with a following "lt;" and decode twice
# ("&amp;lt;" → "&lt;" → "<"), destroying literal entity text.
text = re.sub(
r"&(amp|lt|gt);",
lambda m: {"amp": "&", "lt": "<", "gt": ">"}[m.group(1)],
text,
)
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 7) Convert headers (## Title) → *Title* (bold)

View file

@ -4068,6 +4068,16 @@ class TestFormatMessage:
"""Already-escaped &gt; in plain text must not become &amp;gt;."""
assert adapter.format_message("5 &gt; 3") == "5 &gt; 3"
def test_escaped_entity_text_not_double_decoded(self, adapter):
"""&amp;lt; is the wire form of the literal text &lt; — it must survive.
The unescape pass must not re-scan its own output: decoding &amp; 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("&amp;lt;") == "&amp;lt;"
assert adapter.format_message("&amp;gt;") == "&amp;gt;"
def test_mixed_raw_and_escaped_entities(self, adapter):
"""Raw & and pre-escaped &amp; coexist correctly."""
result = adapter.format_message("AT&T and &amp; entity")