mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
ddf54f264b
commit
03c28795f8
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user