Matrix: harden visible mention trust

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 08:13:48 +00:00
parent dd630b04c8
commit 37286e1a8b
4 changed files with 217 additions and 35 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: '<a href="https://matrix.to/#/@bot:matrix.org">click here</a>: 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:
'<a href="https://matrix.to/#/@bot:matrix.org"><span>@bot</span></a>: hello',
},
userId,
text: "@bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
});
describe("regex patterns", () => {

View File

@@ -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 = /<a\b[^>]*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<string>();
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 };
}