diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index e1939049d8f..cbfaeac7a2e 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -47,7 +47,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { "!room:example.org", createMatrixTextMessageEvent({ eventId: "$reply1", - body: "follow up", + body: "@room follow up", relatesTo: { rel_type: "m.thread", event_id: "$thread-root", diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index cd151b2a16c..308cbda2349 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -3,6 +3,7 @@ import { __testing as sessionBindingTesting, registerSessionBindingAdapter, } from "../../../../../src/infra/outbound/session-binding-service.js"; +import { setMatrixRuntime } from "../../runtime.js"; import { createMatrixHandlerTestHarness, createMatrixReactionEvent, @@ -23,6 +24,23 @@ vi.mock("../send.js", () => ({ beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as never); }); function createReactionHarness(params?: { @@ -62,7 +80,7 @@ describe("matrix monitor handler pairing account scope", () => { "!room:example.org", createMatrixTextMessageEvent({ eventId: "$event1", - body: "hello", + body: "@room hello", mentions: { room: true }, }), ); @@ -71,7 +89,7 @@ describe("matrix monitor handler pairing account scope", () => { "!room:example.org", createMatrixTextMessageEvent({ eventId: "$event2", - body: "hello again", + body: "@room hello again", mentions: { room: true }, }), ); @@ -93,7 +111,7 @@ describe("matrix monitor handler pairing account scope", () => { const makeEvent = (id: string): MatrixRawEvent => createMatrixTextMessageEvent({ eventId: id, - body: "hello", + body: "@room hello", mentions: { room: true }, }); @@ -214,6 +232,26 @@ describe("matrix monitor handler pairing account scope", () => { ); }); + it("drops forged metadata-only mentions before agent routing", async () => { + const { handler, recordInboundSession, resolveAgentRoute } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + mentionRegexes: [/@bot/i], + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$spoofed-mention", + body: "hello there", + mentions: { user_ids: ["@bot:example.org"] }, + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + it("records thread starter context for inbound thread replies", async () => { const { handler, finalizeInboundContext, recordInboundSession } = createMatrixHandlerTestHarness({ @@ -234,7 +272,7 @@ describe("matrix monitor handler pairing account scope", () => { "!room:example.org", createMatrixTextMessageEvent({ eventId: "$reply1", - body: "follow up", + body: "@room follow up", relatesTo: { rel_type: "m.thread", event_id: "$root", @@ -301,7 +339,7 @@ describe("matrix monitor handler pairing account scope", () => { "!room:example", createMatrixTextMessageEvent({ eventId: "$reply1", - body: "follow up", + body: "@room follow up", relatesTo: { rel_type: "m.thread", event_id: "$root", diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index f1ee615e7ef..4407b006add 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -19,7 +19,22 @@ describe("resolveMentions", () => { const mentionRegexes = [/@bot/i]; describe("m.mentions field", () => { - it("detects mention via m.mentions.user_ids", () => { + it("detects mention via m.mentions.user_ids when the visible text also mentions the bot", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hello @bot", + "m.mentions": { user_ids: ["@bot:matrix.org"] }, + }, + userId, + text: "hello @bot", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + expect(result.hasExplicitMention).toBe(true); + }); + + it("does not trust forged m.mentions.user_ids without a visible mention", () => { const result = resolveMentions({ content: { msgtype: "m.text", @@ -30,11 +45,25 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes, }); - expect(result.wasMentioned).toBe(true); - expect(result.hasExplicitMention).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasExplicitMention).toBe(false); }); - it("detects room mention via m.mentions.room", () => { + it("detects room mention via visible @room text", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "@room hello everyone", + "m.mentions": { room: true }, + }, + userId, + text: "@room hello everyone", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + }); + + it("does not trust forged m.mentions.room without visible @room text", () => { const result = resolveMentions({ content: { msgtype: "m.text", @@ -45,7 +74,8 @@ describe("resolveMentions", () => { text: "hello everyone", mentionRegexes, }); - expect(result.wasMentioned).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasExplicitMention).toBe(false); }); }); @@ -119,6 +149,35 @@ describe("resolveMentions", () => { }); expect(result.wasMentioned).toBe(false); }); + + it("does not trust hidden matrix.to links behind unrelated visible text", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "click here: hello", + formatted_body: 'click here: hello', + }, + userId, + text: "click here: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(false); + }); + + it("detects mention when the visible label still names the bot", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "@bot: hello", + formatted_body: + '@bot: hello', + }, + userId, + text: "@bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); }); describe("regex patterns", () => { diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index aa67386221a..a8e5b7b0eb2 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,27 +1,101 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { RoomMessageEventContent } from "./types.js"; +function normalizeVisibleMentionText(value: string): string { + return value + .replace(/<[^>]+>/g, " ") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function extractVisibleMentionText(value?: string): string { + return normalizeVisibleMentionText(value ?? ""); +} + +function resolveMatrixUserLocalpart(userId: string): string | null { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + return null; + } + const colonIndex = trimmed.indexOf(":"); + if (colonIndex <= 1) { + return null; + } + return trimmed.slice(1, colonIndex).trim() || null; +} + +function isVisibleMentionLabel(params: { + text: string; + userId: string; + mentionRegexes: RegExp[]; +}): boolean { + const cleaned = extractVisibleMentionText(params.text); + if (!cleaned) { + return false; + } + if (params.mentionRegexes.some((pattern) => pattern.test(cleaned))) { + return true; + } + const localpart = resolveMatrixUserLocalpart(params.userId); + const candidates = [ + params.userId.trim().toLowerCase(), + localpart, + localpart ? `@${localpart}` : null, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + return candidates.includes(cleaned); +} + +function hasVisibleRoomMention(value?: string): boolean { + const cleaned = extractVisibleMentionText(value); + return /(^|[^a-z0-9_])@room\b/i.test(cleaned); +} + /** - * Check if the formatted_body contains a matrix.to mention link for the given user ID. + * Check if formatted_body contains a matrix.to link whose visible label still + * looks like a real mention for the given user. Do not trust href alone, since + * senders can hide arbitrary matrix.to links behind unrelated link text. * Many Matrix clients (including Element) use HTML links in formatted_body instead of * or in addition to the m.mentions field. */ -function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean { - if (!formattedBody || !userId) { +function checkFormattedBodyMention(params: { + formattedBody?: string; + userId: string; + mentionRegexes: RegExp[]; +}): boolean { + if (!params.formattedBody || !params.userId) { return false; } - // Escape special regex characters in the user ID (e.g., @user:matrix.org) - const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match matrix.to links with the user ID, handling both URL-encoded and plain formats - // Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org" - const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i"); - if (plainPattern.test(formattedBody)) { - return true; + const anchorPattern = /]*href=(["'])(https:\/\/matrix\.to\/#[^"']+)\1[^>]*>(.*?)<\/a>/gis; + for (const match of params.formattedBody.matchAll(anchorPattern)) { + const href = match[2]; + const visibleLabel = match[3] ?? ""; + if (!href) { + continue; + } + try { + const parsed = new URL(href); + const fragmentTarget = decodeURIComponent(parsed.hash.replace(/^#\/?/, "").trim()); + if (fragmentTarget !== params.userId.trim()) { + continue; + } + if ( + isVisibleMentionLabel({ + text: visibleLabel, + userId: params.userId, + mentionRegexes: params.mentionRegexes, + }) + ) { + return true; + } + } catch { + continue; + } } - // Also check URL-encoded version (@ -> %40, : -> %3A) - const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i"); - return encodedPattern.test(formattedBody); + return false; } export function resolveMentions(params: { @@ -34,19 +108,30 @@ export function resolveMentions(params: { const mentionedUsers = Array.isArray(mentions?.user_ids) ? new Set(mentions.user_ids) : new Set(); + const textMentioned = getMatrixRuntime().channel.mentions.matchesMentionPatterns( + params.text ?? "", + params.mentionRegexes, + ); + const visibleRoomMention = + hasVisibleRoomMention(params.text) || hasVisibleRoomMention(params.content.formatted_body); // Check formatted_body for matrix.to mention links (legacy/alternative mention format) const mentionedInFormattedBody = params.userId - ? checkFormattedBodyMention(params.content.formatted_body, params.userId) + ? checkFormattedBodyMention({ + formattedBody: params.content.formatted_body, + userId: params.userId, + mentionRegexes: params.mentionRegexes, + }) : false; + const metadataBackedUserMention = Boolean( + params.userId && + mentionedUsers.has(params.userId) && + (mentionedInFormattedBody || textMentioned), + ); + const metadataBackedRoomMention = Boolean(mentions?.room) && visibleRoomMention; + const explicitMention = + mentionedInFormattedBody || metadataBackedUserMention || metadataBackedRoomMention; - const wasMentioned = - Boolean(mentions?.room) || - (params.userId ? mentionedUsers.has(params.userId) : false) || - mentionedInFormattedBody || - getMatrixRuntime().channel.mentions.matchesMentionPatterns( - params.text ?? "", - params.mentionRegexes, - ); - return { wasMentioned, hasExplicitMention: Boolean(mentions) }; + const wasMentioned = explicitMention || textMentioned || visibleRoomMention; + return { wasMentioned, hasExplicitMention: explicitMention }; }