Skip to content

Commit 4af5fa0

Browse files
Matrix: gate verification notices on DM access
1 parent 5e8cb22 commit 4af5fa0

File tree

4 files changed

+252
-0
lines changed

4 files changed

+252
-0
lines changed

extensions/matrix/src/matrix/monitor/events.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ function createHarness(params?: {
2424
cryptoAvailable?: boolean;
2525
selfUserId?: string;
2626
selfUserIdError?: Error;
27+
allowFrom?: string[];
28+
dmEnabled?: boolean;
29+
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
30+
storeAllowFrom?: string[];
2731
joinedMembersByRoom?: Record<string, string[]>;
2832
verifications?: Array<{
2933
id: string;
@@ -67,6 +71,7 @@ function createHarness(params?: {
6771
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
6872
const formatNativeDependencyHint = vi.fn(() => "install hint");
6973
const logVerboseMessage = vi.fn();
74+
const readStoreAllowFrom = vi.fn(async () => params?.storeAllowFrom ?? []);
7075
const client = {
7176
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
7277
listeners.set(eventName, listener);
@@ -101,6 +106,10 @@ function createHarness(params?: {
101106
accountId: params?.accountId ?? "default",
102107
encryption: params?.authEncryption ?? true,
103108
} as MatrixAuth,
109+
allowFrom: params?.allowFrom ?? [],
110+
dmEnabled: params?.dmEnabled ?? true,
111+
dmPolicy: params?.dmPolicy ?? "open",
112+
readStoreAllowFrom,
104113
directTracker: {
105114
invalidateRoom,
106115
},
@@ -123,6 +132,7 @@ function createHarness(params?: {
123132
invalidateRoom,
124133
roomEventListener,
125134
listVerifications,
135+
readStoreAllowFrom,
126136
logger,
127137
formatNativeDependencyHint,
128138
logVerboseMessage,
@@ -255,6 +265,112 @@ describe("registerMatrixMonitorEvents verification routing", () => {
255265
expect(body).toContain('Open "Verify by emoji"');
256266
});
257267

268+
it("blocks verification request notices when dmPolicy pairing would block the sender", async () => {
269+
const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage } = createHarness({
270+
dmPolicy: "pairing",
271+
});
272+
if (!roomMessageListener) {
273+
throw new Error("room.message listener was not registered");
274+
}
275+
276+
roomMessageListener("!room:example.org", {
277+
event_id: "$req-pairing-blocked",
278+
sender: "@alice:example.org",
279+
type: EventType.RoomMessage,
280+
origin_server_ts: Date.now(),
281+
content: {
282+
msgtype: "m.key.verification.request",
283+
body: "verification request",
284+
},
285+
});
286+
287+
await vi.waitFor(() => {
288+
expect(logVerboseMessage).toHaveBeenCalledWith(
289+
expect.stringContaining("blocked verification sender @alice:example.org"),
290+
);
291+
});
292+
expect(sendMessage).not.toHaveBeenCalled();
293+
expect(onRoomMessage).not.toHaveBeenCalled();
294+
});
295+
296+
it("allows verification notices for pairing-authorized DM senders from the allow store", async () => {
297+
const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({
298+
dmPolicy: "pairing",
299+
storeAllowFrom: ["@alice:example.org"],
300+
});
301+
if (!roomMessageListener) {
302+
throw new Error("room.message listener was not registered");
303+
}
304+
305+
roomMessageListener("!room:example.org", {
306+
event_id: "$req-pairing-allowed",
307+
sender: "@alice:example.org",
308+
type: EventType.RoomMessage,
309+
origin_server_ts: Date.now(),
310+
content: {
311+
msgtype: "m.key.verification.request",
312+
body: "verification request",
313+
},
314+
});
315+
316+
await vi.waitFor(() => {
317+
expect(sendMessage).toHaveBeenCalledTimes(1);
318+
});
319+
expect(readStoreAllowFrom).toHaveBeenCalled();
320+
});
321+
322+
it("does not consult the allow store when dmPolicy is open", async () => {
323+
const { sendMessage, roomMessageListener, readStoreAllowFrom } = createHarness({
324+
dmPolicy: "open",
325+
});
326+
if (!roomMessageListener) {
327+
throw new Error("room.message listener was not registered");
328+
}
329+
330+
roomMessageListener("!room:example.org", {
331+
event_id: "$req-open-policy",
332+
sender: "@alice:example.org",
333+
type: EventType.RoomMessage,
334+
origin_server_ts: Date.now(),
335+
content: {
336+
msgtype: "m.key.verification.request",
337+
body: "verification request",
338+
},
339+
});
340+
341+
await vi.waitFor(() => {
342+
expect(sendMessage).toHaveBeenCalledTimes(1);
343+
});
344+
expect(readStoreAllowFrom).not.toHaveBeenCalled();
345+
});
346+
347+
it("blocks verification notices when Matrix DMs are disabled", async () => {
348+
const { sendMessage, roomMessageListener, logVerboseMessage } = createHarness({
349+
dmEnabled: false,
350+
});
351+
if (!roomMessageListener) {
352+
throw new Error("room.message listener was not registered");
353+
}
354+
355+
roomMessageListener("!room:example.org", {
356+
event_id: "$req-dm-disabled",
357+
sender: "@alice:example.org",
358+
type: EventType.RoomMessage,
359+
origin_server_ts: Date.now(),
360+
content: {
361+
msgtype: "m.key.verification.request",
362+
body: "verification request",
363+
},
364+
});
365+
366+
await vi.waitFor(() => {
367+
expect(logVerboseMessage).toHaveBeenCalledWith(
368+
expect.stringContaining("blocked verification sender @alice:example.org"),
369+
);
370+
});
371+
expect(sendMessage).not.toHaveBeenCalled();
372+
});
373+
258374
it("posts ready-stage guidance for emoji verification", async () => {
259375
const { sendMessage, roomEventListener } = createHarness();
260376
roomEventListener("!room:example.org", {
@@ -423,6 +539,51 @@ describe("registerMatrixMonitorEvents verification routing", () => {
423539
expect(body).toContain("SAS decimal: 6158 1986 3513");
424540
});
425541

542+
it("blocks summary SAS notices when dmPolicy allowlist would block the sender", async () => {
543+
const { sendMessage, verificationSummaryListener, logVerboseMessage } = createHarness({
544+
dmPolicy: "allowlist",
545+
joinedMembersByRoom: {
546+
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
547+
},
548+
});
549+
if (!verificationSummaryListener) {
550+
throw new Error("verification.summary listener was not registered");
551+
}
552+
553+
verificationSummaryListener({
554+
id: "verification-blocked-summary",
555+
roomId: "!dm:example.org",
556+
otherUserId: "@alice:example.org",
557+
isSelfVerification: false,
558+
initiatedByMe: false,
559+
phase: 3,
560+
phaseName: "started",
561+
pending: true,
562+
methods: ["m.sas.v1"],
563+
canAccept: false,
564+
hasSas: true,
565+
sas: {
566+
decimal: [6158, 1986, 3513],
567+
emoji: [
568+
["🎁", "Gift"],
569+
["🌍", "Globe"],
570+
["🐴", "Horse"],
571+
],
572+
},
573+
hasReciprocateQr: false,
574+
completed: false,
575+
createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
576+
updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(),
577+
});
578+
579+
await vi.waitFor(() => {
580+
expect(logVerboseMessage).toHaveBeenCalledWith(
581+
expect.stringContaining("blocked verification sender @alice:example.org"),
582+
);
583+
});
584+
expect(sendMessage).not.toHaveBeenCalled();
585+
});
586+
426587
it("posts SAS notices from summary updates using the room mapped by earlier flow events", async () => {
427588
const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({
428589
joinedMembersByRoom: {

extensions/matrix/src/matrix/monitor/events.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export function registerMatrixMonitorEvents(params: {
3434
cfg: CoreConfig;
3535
client: MatrixClient;
3636
auth: MatrixAuth;
37+
allowFrom: string[];
38+
dmEnabled: boolean;
39+
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
40+
readStoreAllowFrom: () => Promise<string[]>;
3741
directTracker?: {
3842
invalidateRoom: (roomId: string) => void;
3943
};
@@ -48,6 +52,10 @@ export function registerMatrixMonitorEvents(params: {
4852
cfg,
4953
client,
5054
auth,
55+
allowFrom,
56+
dmEnabled,
57+
dmPolicy,
58+
readStoreAllowFrom,
5159
directTracker,
5260
logVerboseMessage,
5361
warnedEncryptedRooms,
@@ -58,6 +66,10 @@ export function registerMatrixMonitorEvents(params: {
5866
} = params;
5967
const { routeVerificationEvent, routeVerificationSummary } = createMatrixVerificationEventRouter({
6068
client,
69+
allowFrom,
70+
dmEnabled,
71+
dmPolicy,
72+
readStoreAllowFrom,
6173
logVerboseMessage,
6274
});
6375

extensions/matrix/src/matrix/monitor/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
266266
cfg,
267267
client,
268268
auth,
269+
allowFrom,
270+
dmEnabled,
271+
dmPolicy,
272+
readStoreAllowFrom: async () =>
273+
await core.channel.pairing
274+
.readAllowFromStore({
275+
channel: "matrix",
276+
env: process.env,
277+
accountId: account.accountId,
278+
})
279+
.catch(() => []),
269280
directTracker,
270281
logVerboseMessage,
271282
warnedEncryptedRooms,

extensions/matrix/src/matrix/monitor/verification-events.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { inspectMatrixDirectRooms } from "../direct-management.js";
22
import { isStrictDirectRoom } from "../direct-room.js";
33
import type { MatrixClient } from "../sdk.js";
4+
import { resolveMatrixMonitorAccessState } from "./access-state.js";
45
import type { MatrixRawEvent } from "./types.js";
56
import { EventType } from "./types.js";
67
import {
@@ -309,8 +310,51 @@ async function sendVerificationNotice(params: {
309310
}
310311
}
311312

313+
async function isVerificationNoticeAuthorized(params: {
314+
senderId: string;
315+
allowFrom: string[];
316+
dmEnabled: boolean;
317+
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
318+
readStoreAllowFrom: () => Promise<string[]>;
319+
logVerboseMessage: (message: string) => void;
320+
}): Promise<boolean> {
321+
// Verification notices are DM-only. If DM ingress is disabled, there is no
322+
// policy-compatible path for posting these notices back into the room.
323+
if (!params.dmEnabled || params.dmPolicy === "disabled") {
324+
params.logVerboseMessage(
325+
`matrix: blocked verification sender ${params.senderId} (dmPolicy=${params.dmPolicy}, dmEnabled=${String(params.dmEnabled)})`,
326+
);
327+
return false;
328+
}
329+
if (params.dmPolicy === "open") {
330+
return true;
331+
}
332+
const storeAllowFrom = await params.readStoreAllowFrom();
333+
const accessState = resolveMatrixMonitorAccessState({
334+
allowFrom: params.allowFrom,
335+
storeAllowFrom,
336+
// Verification flows only exist in strict DMs, so room/group allowlists do
337+
// not participate in the authorization decision here.
338+
groupAllowFrom: [],
339+
roomUsers: [],
340+
senderId: params.senderId,
341+
isRoom: false,
342+
});
343+
if (accessState.directAllowMatch.allowed) {
344+
return true;
345+
}
346+
params.logVerboseMessage(
347+
`matrix: blocked verification sender ${params.senderId} (dmPolicy=${params.dmPolicy})`,
348+
);
349+
return false;
350+
}
351+
312352
export function createMatrixVerificationEventRouter(params: {
313353
client: MatrixClient;
354+
allowFrom: string[];
355+
dmEnabled: boolean;
356+
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
357+
readStoreAllowFrom: () => Promise<string[]>;
314358
logVerboseMessage: (message: string) => void;
315359
}) {
316360
const routerStartedAtMs = Date.now();
@@ -411,6 +455,18 @@ export function createMatrixVerificationEventRouter(params: {
411455
);
412456
return;
413457
}
458+
if (
459+
!(await isVerificationNoticeAuthorized({
460+
senderId: summary.otherUserId,
461+
allowFrom: params.allowFrom,
462+
dmEnabled: params.dmEnabled,
463+
dmPolicy: params.dmPolicy,
464+
readStoreAllowFrom: params.readStoreAllowFrom,
465+
logVerboseMessage: params.logVerboseMessage,
466+
}))
467+
) {
468+
return;
469+
}
414470
const sasNotice = formatVerificationSasNotice(summary);
415471
if (!sasNotice) {
416472
return;
@@ -459,6 +515,18 @@ export function createMatrixVerificationEventRouter(params: {
459515
);
460516
return;
461517
}
518+
if (
519+
!(await isVerificationNoticeAuthorized({
520+
senderId,
521+
allowFrom: params.allowFrom,
522+
dmEnabled: params.dmEnabled,
523+
dmPolicy: params.dmPolicy,
524+
readStoreAllowFrom: params.readStoreAllowFrom,
525+
logVerboseMessage: params.logVerboseMessage,
526+
}))
527+
) {
528+
return;
529+
}
462530
rememberVerificationUserRoom(senderId, roomId);
463531
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
464532
return;

0 commit comments

Comments
 (0)