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
This commit is contained in:
Peter Steinberger
2026-08-01 22:18:56 -07:00
committed by GitHub
parent ddf54f264b
commit 03c28795f8
3 changed files with 331 additions and 4 deletions
@@ -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({
@@ -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: {
@@ -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 =