From 03c28795f8c696bcffa231e230b89a7987f50b61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 22:18:56 -0700 Subject: [PATCH] fix(matrix): bot ignores valid plain-text room mentions (#117764) * fix(matrix): recognize native plain-text room mentions * fix(matrix): reject Unicode account-prefix mention spoofing * fix(matrix): enforce complete native mention account boundaries * fix(matrix): recognize full user ID command mentions --- .../matrix/src/matrix/monitor/handler.test.ts | 61 +++++ .../src/matrix/monitor/mentions.test.ts | 250 ++++++++++++++++++ .../matrix/src/matrix/monitor/mentions.ts | 24 +- 3 files changed, 331 insertions(+), 4 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index ac48308c5c99..12d0dae09055 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -952,6 +952,67 @@ describe("matrix monitor handler pairing account scope", () => { }, ); + it.each([ + { label: "full Matrix user ID", body: "hello @bot:example.org" }, + { label: "colon-delimited full Matrix user ID", body: "@bot:example.org: help" }, + { label: "Unicode-whitespace-colon-delimited full ID", body: "@bot:example.org:\u2003help" }, + { label: "localpart shorthand", body: "hello @bot" }, + ])("processes native plain-text $label without configured mention patterns", async ({ body }) => { + const getMemberDisplayName = vi.fn(async () => "sender"); + const { handler, recordInboundSession, runPrepared } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + mentionRegexes: [], + getMemberDisplayName, + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$native-plain-text-mention", + body, + mentions: { user_ids: ["@bot:example.org"] }, + }), + ); + + expect(recordInboundSession).toHaveBeenCalledOnce(); + expect(runPrepared.mock.calls[0]?.[0].ctxPayload).toMatchObject({ + AccountId: "ops", + WasMentioned: true, + }); + expect(getMemberDisplayName).not.toHaveBeenCalledWith("!room:example.org", "@bot:example.org"); + }); + + it.each([ + { label: "another homeserver", body: "hello @bot:evil.example" }, + { label: "an unexpected homeserver port", body: "@bot:example.org:8448: help" }, + { label: "an invisible full-ID command separator", body: "@bot:example.org:\ufeffhelp" }, + { label: "a room-alias full-ID command collision", body: "#@bot:example.org: help" }, + { label: "a longer Unicode localpart", body: "hello @boté" }, + { label: "a historical exclamation localpart", body: "hello @bot!evil:evil.example" }, + { label: "a historical percent localpart", body: "hello @bot%evil:evil.example" }, + { label: "an exclamation-only historical account", body: "hello @bot!" }, + { label: "a percent-only historical account", body: "hello @bot%" }, + { label: "a Markdown-only historical account", body: "hello @bot**" }, + { label: "an attached Markdown-wrapped account", body: "hello evil**@bot" }, + ])("rejects forged plain-text native mentions targeting $label", async ({ body }) => { + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + mentionRegexes: [], + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$foreign-native-mention", + body, + mentions: { user_ids: ["@bot:example.org"] }, + }), + ); + + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + it("processes room messages mentioned via displayName in formatted_body", async () => { const recordInboundSession = vi.fn(async () => {}); const { handler } = createMatrixHandlerTestHarness({ diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index 013453e95a7e..79497bd6ce70 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -35,6 +35,256 @@ describe("resolveMentions", () => { expect(result.hasExplicitMention).toBe(true); }); + it.each([ + { + label: "full Matrix user ID", + mentionedUserId: "@bot:matrix.org", + body: "hello @bot:matrix.org", + }, + { + label: "colon-delimited full Matrix user ID", + mentionedUserId: "@bot:matrix.org", + body: "@bot:matrix.org: help", + }, + { + label: "Unicode-whitespace-colon-delimited full Matrix user ID", + mentionedUserId: "@bot:matrix.org", + body: "@bot:matrix.org:\u2003help", + }, + { + label: "colon-delimited full Matrix user ID with a homeserver port", + mentionedUserId: "@bot:matrix.org:8448", + body: "@bot:matrix.org:8448: help", + }, + { + label: "localpart shorthand", + mentionedUserId: "@bot:matrix.org", + body: "hello @bot", + }, + { + label: "colon-delimited shorthand", + mentionedUserId: "@bot:matrix.org", + body: "@bot: hello", + }, + { + label: "Unicode-whitespace-delimited shorthand", + mentionedUserId: "@bot:matrix.org", + body: "hello\u2003@bot\u2003thanks", + }, + { + label: "sentence-ending full Matrix user ID", + mentionedUserId: "@bot:matrix.org", + body: "hello @bot:matrix.org,", + }, + { + label: "special localpart characters", + mentionedUserId: "@foo/bar+baz=ok:matrix.org", + body: "hello @foo/bar+baz=ok", + }, + { + label: "historical Unicode localpart", + mentionedUserId: "@böt中:matrix.org", + body: "hello @böt中", + }, + { + label: "historical punctuation localpart", + mentionedUserId: "@b!o%t&=x:matrix.org", + body: "hello @b!o%t&=x", + }, + { + label: "historical punctuation in a full Matrix user ID", + mentionedUserId: "@b!o%t&=x:matrix.org", + body: "hello @b!o%t&=x:matrix.org", + }, + { + label: "bracketed IPv6 homeserver and port", + mentionedUserId: "@bot:[2001:db8::1]:8448", + body: "hello @bot:[2001:db8::1]:8448", + }, + { + label: "colon-delimited bracketed IPv6 homeserver and port", + mentionedUserId: "@bot:[2001:db8::1]:8448", + body: "@bot:[2001:db8::1]:8448:\u2003help", + }, + ])( + "detects native plain-text $label without configured mention patterns", + ({ mentionedUserId, body }) => { + const params = { + content: { + msgtype: "m.text", + body, + "m.mentions": { user_ids: [mentionedUserId] }, + }, + userId: mentionedUserId, + text: body, + mentionRegexes: [], + }; + const expected = { wasMentioned: true, hasExplicitMention: true }; + + expect(resolveMentions(params)).toEqual(expected); + expect(resolveMentions(params)).toEqual(expected); + }, + ); + + it.each([ + { label: "same localpart on another homeserver", body: "hello @bot:evil.example" }, + { label: "case-different homeserver", body: "hello @bot:MATRIX.ORG" }, + { label: "extended DNS homeserver", body: "hello @bot:matrix.org.evil" }, + { label: "unexpected homeserver port", body: "hello @bot:matrix.org:8448" }, + { label: "unexpected homeserver port before a command", body: "@bot:matrix.org:8448: help" }, + { label: "unexpected repeated homeserver colon", body: "@bot:matrix.org::8448" }, + { label: "full Matrix user ID followed by colon alone", body: "@bot:matrix.org:" }, + { + label: "full Matrix user ID followed by invisible BOM", + body: "@bot:matrix.org:\ufeffhelp", + }, + { + label: "full Matrix user ID followed by invisible zero-width text", + body: "@bot:matrix.org:\u200bhelp", + }, + { + label: "full Matrix user ID followed by bidirectional formatting", + body: "@bot:matrix.org:\u202ehelp", + }, + { + label: "full Matrix user ID followed by a hidden control", + body: "@bot:matrix.org:\u0001help", + }, + { label: "alternate IPv6 homeserver", body: "hello @bot:[::1]" }, + { label: "extended dotted localpart", body: "hello @bot.extra" }, + { label: "extended plus localpart", body: "hello @bot+evil" }, + { label: "extended hyphenated localpart", body: "hello @bot-evil" }, + { label: "extended slash localpart", body: "hello @bot/evil" }, + { label: "historical exclamation localpart", body: "hello @bot!evil:evil.example" }, + { label: "historical percent localpart", body: "hello @bot%evil:evil.example" }, + { label: "historical ampersand localpart", body: "hello @bot&evil:evil.example" }, + { label: "historical question-mark localpart", body: "hello @bot?evil:evil.example" }, + { label: "historical closing-bracket localpart", body: "hello @bot)evil:evil.example" }, + { label: "historical Markdown localpart", body: "hello @bot**evil:evil.example" }, + { label: "historical exclamation-only localpart", body: "hello @bot!" }, + { label: "historical percent-only localpart", body: "hello @bot%" }, + { label: "historical comma-only localpart", body: "hello @bot," }, + { label: "historical period-only localpart", body: "hello @bot." }, + { label: "historical parenthesis-only localpart", body: "hello @bot)" }, + { label: "historical Markdown-only localpart", body: "hello @bot**" }, + { label: "historical hash-only localpart", body: "hello @bot#" }, + { label: "ambiguous parenthesized shorthand", body: "hello (@bot)" }, + { label: "ambiguous Markdown-wrapped shorthand", body: "hello **@bot**" }, + { label: "ambiguous hash-wrapped shorthand", body: "hello #@bot#" }, + { label: "Matrix room-alias account collision", body: "hello #@bot:matrix.org" }, + { label: "Matrix room-alias colon-command collision", body: "#@bot:matrix.org: help" }, + { label: "Matrix room-ID account collision", body: "hello !@bot:matrix.org" }, + { label: "Matrix event-ID account collision", body: "hello $@bot:matrix.org" }, + { label: "unseparated Unicode punctuation", body: "hello (@bot),thanks" }, + { label: "extended accented localpart", body: "hello @boté" }, + { label: "extended CJK localpart", body: "hello @bot中" }, + { label: "extended combining-mark localpart", body: "hello @bot\u0301" }, + { label: "extended Unicode-numeral localpart", body: "hello @bot\u0661" }, + { label: "extended Unicode-connector localpart", body: "hello @bot\u203fevil" }, + { label: "extended currency-symbol localpart", body: "hello @bot€" }, + { label: "extended ASCII-currency localpart", body: "hello @bot$" }, + { label: "extended mathematical-symbol localpart", body: "hello @bot∑" }, + { label: "extended emoji localpart", body: "hello @bot\u{1f600}" }, + { label: "extended reserved emoji localpart", body: "hello @bot\u{1f02c}" }, + { label: "extended flag-emoji localpart", body: "hello @bot\u{1f1fa}\u{1f1f8}" }, + { label: "extended emoji-modifier localpart", body: "hello @bot\u{1f3fb}" }, + { label: "extended joined-emoji localpart", body: "hello @bot\u200d\u{1f4bb}" }, + { label: "extended Unicode homeserver", body: "hello @bot:matrix.orgé" }, + { label: "embedded email token", body: "hello contact@bot" }, + { label: "embedded opening-punctuation token", body: "hello evil(@bot" }, + { label: "embedded Markdown token", body: "hello evil**@bot" }, + { label: "embedded zero-width prefix", body: "hello \u200b@bot" }, + { label: "embedded BOM prefix", body: "hello \ufeff@bot" }, + { label: "embedded accented token", body: "hello é@bot" }, + { label: "embedded CJK token", body: "hello 中@bot" }, + { label: "embedded combining-mark token", body: "hello \u0301@bot" }, + { label: "embedded Unicode-numeral token", body: "hello \u0661@bot" }, + { label: "embedded Unicode-connector token", body: "hello \u203f@bot" }, + { label: "embedded currency-symbol token", body: "hello €@bot" }, + { label: "embedded mathematical-symbol token", body: "hello ∑@bot" }, + { label: "embedded emoji token", body: "hello \u{1f600}@bot" }, + { label: "embedded flag-emoji token", body: "hello \u{1f1fa}@bot" }, + { label: "embedded emoji-modifier token", body: "hello \u{1f3fb}@bot" }, + { label: "adjacent mention prefix", body: "hello @@bot" }, + { label: "zero-width foreign homeserver", body: "hello @bot\u200b:evil.example" }, + { label: "zero-width domain extension", body: "hello @bot:matrix.org\u200b.evil" }, + { label: "bidirectional foreign homeserver", body: "hello @bot\u202e:evil.example" }, + { label: "BOM foreign homeserver", body: "hello @bot\ufeff:evil.example" }, + { label: "invisible combining grapheme joiner", body: "hello @bot\u034f:evil.example" }, + { label: "invisible variation selector", body: "hello @bot\ufe0f:evil.example" }, + { label: "invisible Hangul filler", body: "hello @bot\u3164:evil.example" }, + ])("rejects forged native mention metadata for $label", ({ body }) => { + expect( + resolveMentions({ + content: { + msgtype: "m.text", + body, + "m.mentions": { user_ids: [userId] }, + }, + userId, + text: body, + mentionRegexes: [], + }), + ).toEqual({ wasMentioned: false, hasExplicitMention: false }); + }); + + it.each([ + ...Array.from({ length: 94 }, (_, index) => String.fromCharCode(33 + index)).filter( + (character) => character !== ":", + ), + "!", + "%", + ",", + "。", + "؛", + "‽", + "・", + "、", + "…", + "—", + "(", + ")", + "«", + "»", + ])("rejects historical-account continuation or prefix %s", (character) => { + for (const body of [ + `hello @bot${character}`, + `hello @bot${character}${character}`, + `hello @bot${character}evil:evil.example`, + `hello evil${character}@bot`, + ]) { + expect( + resolveMentions({ + content: { + msgtype: "m.text", + body, + "m.mentions": { user_ids: [userId] }, + }, + userId, + text: body, + mentionRegexes: [], + }), + ).toEqual({ wasMentioned: false, hasExplicitMention: false }); + } + }); + + it("requires metadata to name the exact account even when that account is visibly mentioned", () => { + const body = "hello @bot:matrix.org"; + + expect( + resolveMentions({ + content: { + msgtype: "m.text", + body, + "m.mentions": { user_ids: ["@bot:evil.example"] }, + }, + userId, + text: body, + mentionRegexes: [], + }), + ).toEqual({ wasMentioned: false, hasExplicitMention: false }); + }); + it("does not trust m.mentions.user_ids without a visible text or formatted mention", () => { const result = resolveMentions({ content: { diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 1fdc22110165..f9a650ed1e64 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -38,6 +38,21 @@ function resolveMatrixUserLocalpart(userId: string): string | null { return trimmed.slice(1, colonIndex).trim() || null; } +function hasVisibleNativeMatrixUserMention(text: string | undefined, userId: string): boolean { + const localpart = resolveMatrixUserLocalpart(userId); + if (!text || !localpart) { + return false; + } + + // Historical localparts can end in any punctuation, so shorthand must stay + // bare; colon plus visible whitespace is safe because localparts forbid colon. + const pattern = new RegExp( + String.raw`(?:^|\p{White_Space})(?:${escapeRegExp(userId)}(?=$|\p{White_Space}|:\p{White_Space}|[,!?;](?=$|\p{White_Space}))|${escapeRegExp(`@${localpart}`)}(?=$|\p{White_Space}|:\p{White_Space}))`, + "u", + ); + return pattern.test(text); +} + function resolveMatrixMentionPrefixCandidates(params: { userId?: string | null; displayName?: string | null; @@ -216,13 +231,14 @@ export function resolveMentions(params: { mentionRegexes: params.mentionRegexes, }) : false; - // Matrix clients can mention users through m.mentions metadata plus a visible - // Matrix URI label in formatted_body. Keep the visible-mention requirement so - // hidden metadata-only mentions do not trigger the handler. + // Native mentions may use visible plain-text Matrix IDs without HTML. Keep + // exact metadata ownership and visibility so forged mentions stay inert. const metadataBackedUserMention = Boolean( params.userId && mentionedUsers.has(params.userId) && - (mentionedInFormattedBody || textMentioned), + (mentionedInFormattedBody || + textMentioned || + hasVisibleNativeMatrixUserMention(params.text, params.userId)), ); const metadataBackedRoomMention = Boolean(mentions?.room) && visibleRoomMention; const explicitMention =