diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index bd4caa97fa7..9cfdd40cfa2 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -24,6 +24,10 @@ function createHarness(params?: { cryptoAvailable?: boolean; selfUserId?: string; selfUserIdError?: Error; + allowFrom?: string[]; + dmEnabled?: boolean; + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + storeAllowFrom?: string[]; joinedMembersByRoom?: Record; verifications?: Array<{ id: string; @@ -67,6 +71,7 @@ function createHarness(params?: { const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const formatNativeDependencyHint = vi.fn(() => "install hint"); const logVerboseMessage = vi.fn(); + const readStoreAllowFrom = vi.fn(async () => params?.storeAllowFrom ?? []); const client = { on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { listeners.set(eventName, listener); @@ -101,6 +106,10 @@ function createHarness(params?: { accountId: params?.accountId ?? "default", encryption: params?.authEncryption ?? true, } as MatrixAuth, + allowFrom: params?.allowFrom ?? [], + dmEnabled: params?.dmEnabled ?? true, + dmPolicy: params?.dmPolicy ?? "open", + readStoreAllowFrom, directTracker: { invalidateRoom, }, @@ -123,6 +132,7 @@ function createHarness(params?: { invalidateRoom, roomEventListener, listVerifications, + readStoreAllowFrom, logger, formatNativeDependencyHint, logVerboseMessage, @@ -255,6 +265,112 @@ describe("registerMatrixMonitorEvents verification routing", () => { expect(body).toContain('Open "Verify by emoji"'); }); + it("blocks verification request notices when dmPolicy pairing would block the sender", async () => { + const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage } = createHarness({ + dmPolicy: "pairing", + }); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + + roomMessageListener("!room:example.org", { + event_id: "$req-pairing-blocked", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.key.verification.request", + body: "verification request", + }, + }); + + await vi.waitFor(() => { + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("blocked verification sender @alice:example.org"), + ); + }); + expect(sendMessage).not.toHaveBeenCalled(); + expect(onRoomMessage).not.toHaveBeenCalled(); + }); + + it("allows verification notices for pairing-authorized DM senders from the allow store", async () => { + const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({ + dmPolicy: "pairing", + storeAllowFrom: ["@alice:example.org"], + }); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + + roomMessageListener("!room:example.org", { + event_id: "$req-pairing-allowed", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.key.verification.request", + body: "verification request", + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + expect(readStoreAllowFrom).toHaveBeenCalled(); + }); + + it("does not consult the allow store when dmPolicy is open", async () => { + const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({ + dmPolicy: "open", + }); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + + roomMessageListener("!room:example.org", { + event_id: "$req-open-policy", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.key.verification.request", + body: "verification request", + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + expect(readStoreAllowFrom).not.toHaveBeenCalled(); + }); + + it("blocks verification notices when Matrix DMs are disabled", async () => { + const { sendMessage, roomMessageListener, logVerboseMessage } = createHarness({ + dmEnabled: false, + }); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + + roomMessageListener("!room:example.org", { + event_id: "$req-dm-disabled", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.key.verification.request", + body: "verification request", + }, + }); + + await vi.waitFor(() => { + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("blocked verification sender @alice:example.org"), + ); + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("posts ready-stage guidance for emoji verification", async () => { const { sendMessage, roomEventListener } = createHarness(); roomEventListener("!room:example.org", { @@ -423,6 +539,51 @@ describe("registerMatrixMonitorEvents verification routing", () => { expect(body).toContain("SAS decimal: 6158 1986 3513"); }); + it("blocks summary SAS notices when dmPolicy allowlist would block the sender", async () => { + const { sendMessage, verificationSummaryListener, logVerboseMessage } = createHarness({ + dmPolicy: "allowlist", + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + verificationSummaryListener({ + id: "verification-blocked-summary", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("blocked verification sender @alice:example.org"), + ); + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("posts SAS notices from summary updates using the room mapped by earlier flow events", async () => { const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({ joinedMembersByRoom: { diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 81c000e8c58..bfb8f89fd45 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -34,6 +34,10 @@ export function registerMatrixMonitorEvents(params: { cfg: CoreConfig; client: MatrixClient; auth: MatrixAuth; + allowFrom: string[]; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + readStoreAllowFrom: () => Promise; directTracker?: { invalidateRoom: (roomId: string) => void; }; @@ -48,6 +52,10 @@ export function registerMatrixMonitorEvents(params: { cfg, client, auth, + allowFrom, + dmEnabled, + dmPolicy, + readStoreAllowFrom, directTracker, logVerboseMessage, warnedEncryptedRooms, @@ -58,6 +66,10 @@ export function registerMatrixMonitorEvents(params: { } = params; const { routeVerificationEvent, routeVerificationSummary } = createMatrixVerificationEventRouter({ client, + allowFrom, + dmEnabled, + dmPolicy, + readStoreAllowFrom, logVerboseMessage, }); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 71efc539424..035c346b642 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -266,6 +266,17 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi cfg, client, auth, + allowFrom, + dmEnabled, + dmPolicy, + readStoreAllowFrom: async () => + await core.channel.pairing + .readAllowFromStore({ + channel: "matrix", + env: process.env, + accountId: account.accountId, + }) + .catch(() => []), directTracker, logVerboseMessage, warnedEncryptedRooms, diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts index 2fb770dabce..2eac41eb7ee 100644 --- a/extensions/matrix/src/matrix/monitor/verification-events.ts +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -1,6 +1,7 @@ import { inspectMatrixDirectRooms } from "../direct-management.js"; import { isStrictDirectRoom } from "../direct-room.js"; import type { MatrixClient } from "../sdk.js"; +import { resolveMatrixMonitorAccessState } from "./access-state.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; import { @@ -309,8 +310,51 @@ async function sendVerificationNotice(params: { } } +async function isVerificationNoticeAuthorized(params: { + senderId: string; + allowFrom: string[]; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + readStoreAllowFrom: () => Promise; + logVerboseMessage: (message: string) => void; +}): Promise { + // Verification notices are DM-only. If DM ingress is disabled, there is no + // policy-compatible path for posting these notices back into the room. + if (!params.dmEnabled || params.dmPolicy === "disabled") { + params.logVerboseMessage( + `matrix: blocked verification sender ${params.senderId} (dmPolicy=${params.dmPolicy}, dmEnabled=${String(params.dmEnabled)})`, + ); + return false; + } + if (params.dmPolicy === "open") { + return true; + } + const storeAllowFrom = await params.readStoreAllowFrom(); + const accessState = resolveMatrixMonitorAccessState({ + allowFrom: params.allowFrom, + storeAllowFrom, + // Verification flows only exist in strict DMs, so room/group allowlists do + // not participate in the authorization decision here. + groupAllowFrom: [], + roomUsers: [], + senderId: params.senderId, + isRoom: false, + }); + if (accessState.directAllowMatch.allowed) { + return true; + } + params.logVerboseMessage( + `matrix: blocked verification sender ${params.senderId} (dmPolicy=${params.dmPolicy})`, + ); + return false; +} + export function createMatrixVerificationEventRouter(params: { client: MatrixClient; + allowFrom: string[]; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + readStoreAllowFrom: () => Promise; logVerboseMessage: (message: string) => void; }) { const routerStartedAtMs = Date.now(); @@ -411,6 +455,18 @@ export function createMatrixVerificationEventRouter(params: { ); return; } + if ( + !(await isVerificationNoticeAuthorized({ + senderId: summary.otherUserId, + allowFrom: params.allowFrom, + dmEnabled: params.dmEnabled, + dmPolicy: params.dmPolicy, + readStoreAllowFrom: params.readStoreAllowFrom, + logVerboseMessage: params.logVerboseMessage, + })) + ) { + return; + } const sasNotice = formatVerificationSasNotice(summary); if (!sasNotice) { return; @@ -459,6 +515,18 @@ export function createMatrixVerificationEventRouter(params: { ); return; } + if ( + !(await isVerificationNoticeAuthorized({ + senderId, + allowFrom: params.allowFrom, + dmEnabled: params.dmEnabled, + dmPolicy: params.dmPolicy, + readStoreAllowFrom: params.readStoreAllowFrom, + logVerboseMessage: params.logVerboseMessage, + })) + ) { + return; + } rememberVerificationUserRoom(senderId, roomId); if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { return;