mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
Matrix: harden visible mention trust
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user