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 };
}