Matrix: gate verification notices on DM access (#55122)

This commit is contained in:
Jacob Tomlinson
2026-03-26 04:59:20 -07:00
committed by GitHub
parent e43600c9e5
commit 2383daf5c4
4 changed files with 252 additions and 0 deletions

View File

@@ -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<string, string[]>;
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: {

View File

@@ -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<string[]>;
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,
});

View File

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

View File

@@ -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<string[]>;
logVerboseMessage: (message: string) => void;
}): Promise<boolean> {
// 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<string[]>;
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;