fix(matrix): harden allowlists

This commit is contained in:
Peter Steinberger
2026-02-03 09:33:30 -08:00
parent f8dfd034f5
commit 8f3bfbd1c4
13 changed files with 358 additions and 105 deletions

View File

@@ -24,7 +24,7 @@ import {
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { resolveMatrixAuth } from "./matrix/client.js";
import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
@@ -144,7 +144,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}),
resolveAllowFrom: ({ cfg }) =>
((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => ({
@@ -153,11 +153,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
policyPath: "channels.matrix.dm.policy",
allowFromPath: "channels.matrix.dm.allowFrom",
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) =>
raw
.replace(/^matrix:/i, "")
.trim()
.toLowerCase(),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
}),
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
describe("resolveMatrixAllowListMatch", () => {
it("matches full user IDs and prefixes", () => {
const userId = "@Alice:Example.org";
const direct = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
userId,
});
expect(direct.allowed).toBe(true);
expect(direct.matchSource).toBe("id");
const prefixedMatrix = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
userId,
});
expect(prefixedMatrix.allowed).toBe(true);
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
const prefixedUser = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
userId,
});
expect(prefixedUser.allowed).toBe(true);
expect(prefixedUser.matchSource).toBe("prefixed-user");
});
it("ignores display names and localparts", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(false);
});
it("matches wildcard", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["*"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(true);
expect(match.matchSource).toBe("wildcard");
});
});

View File

@@ -4,22 +4,71 @@ function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
export function normalizeAllowListLower(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
function normalizeMatrixUser(raw?: string | null): string {
const value = (raw ?? "").trim();
if (!value) {
return "";
}
if (!value.startsWith("@") || !value.includes(":")) {
return value.toLowerCase();
}
const withoutAt = value.slice(1);
const splitIndex = withoutAt.indexOf(":");
if (splitIndex === -1) {
return value.toLowerCase();
}
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
if (!server) {
return value.toLowerCase();
}
return `@${localpart}:${server.toLowerCase()}`;
}
function normalizeMatrixUser(raw?: string | null): string {
return (raw ?? "").trim().toLowerCase();
export function normalizeMatrixUserId(raw?: string | null): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return normalizeMatrixUser(trimmed.slice("matrix:".length));
}
if (lowered.startsWith("user:")) {
return normalizeMatrixUser(trimmed.slice("user:".length));
}
return normalizeMatrixUser(trimmed);
}
function normalizeMatrixAllowListEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return trimmed;
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
}
if (lowered.startsWith("user:")) {
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
}
return normalizeMatrixUser(trimmed);
}
export function normalizeMatrixAllowList(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
}
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart"
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
userName?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) {
@@ -29,14 +78,10 @@ export function resolveMatrixAllowListMatch(params: {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
{ value: userName, source: "name" },
{ value: localPart, source: "localpart" },
];
for (const candidate of candidates) {
if (!candidate.value) {
@@ -53,10 +98,6 @@ export function resolveMatrixAllowListMatch(params: {
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: {
allowList: string[];
userId?: string;
userName?: string;
}) {
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
return resolveMatrixAllowListMatch(params).allowed;
}

View File

@@ -23,9 +23,9 @@ import {
sendTypingMatrix,
} from "../send.js";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
normalizeAllowListLower,
} from "./allowlist.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
@@ -236,12 +236,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("matrix")
.catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeAllowListLower([
...groupAllowFrom,
...storeAllowFrom,
]);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
@@ -252,7 +249,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
@@ -297,9 +293,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const roomUsers = roomConfig?.users ?? [];
if (isRoom && roomUsers.length > 0) {
const userMatch = resolveMatrixAllowListMatch({
allowList: normalizeAllowListLower(roomUsers),
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
userName: senderName,
});
if (!userMatch.allowed) {
logVerboseMessage(
@@ -314,7 +309,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
userName: senderName,
});
if (!groupAllowMatch.allowed) {
logVerboseMessage(
@@ -387,21 +381,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
userName: senderName,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeAllowListLower(roomUsers),
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
userName: senderName,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);

View File

@@ -10,6 +10,7 @@ import {
resolveSharedMatrixClient,
stopSharedClient,
} from "../client.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
@@ -68,68 +69,94 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
.replace(/^(room|channel):/i, "")
.trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const resolveUserAllowlist = async (
label: string,
list?: Array<string | number>,
): Promise<string[]> => {
let allowList = list ?? [];
if (allowList.length === 0) {
return allowList;
}
const entries = allowList
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length === 0) {
return allowList;
}
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(normalizeMatrixUserId(entry));
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
const normalizedId = normalizeMatrixUserId(entry.id);
additions.push(normalizedId);
mapping.push(`${entry.input}${normalizedId}`);
} else {
unresolved.push(entry.input);
}
}
}
allowList = mergeAllowlist({ existing: allowList, additions });
summarizeMapping(label, mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
);
}
return allowList;
};
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
let groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
if (allowFrom.length > 0) {
const entries = allowFrom
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(entry);
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
additions.push(entry.id);
mapping.push(`${entry.input}${entry.id}`);
} else {
unresolved.push(entry.input);
}
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("matrix users", mapping, unresolved, runtime);
}
}
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms = { ...roomsConfig };
const pending: Array<{ input: string; query: string }> = [];
for (const entry of entries) {
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
if (roomsConfig["*"]) {
nextRooms["*"] = roomsConfig["*"];
}
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
[];
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
if (entry === "*") {
continue;
}
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if (cleaned.startsWith("!") && cleaned.includes(":")) {
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomsConfig[entry];
nextRooms[cleaned] = roomConfig;
}
if (cleaned !== entry) {
mapping.push(`${entry}${cleaned}`);
}
mapping.push(`${entry}${cleaned}`);
continue;
}
pending.push({ input: entry, query: trimmed });
pending.push({ input: entry, query: trimmed, config: roomConfig });
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
@@ -145,7 +172,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = roomsConfig[source.input];
nextRooms[entry.id] = source.config;
}
mapping.push(`${source.input}${entry.id}`);
} else {
@@ -155,6 +182,25 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const nextRooms = { ...roomsConfig };
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
const users = roomConfig?.users ?? [];
if (users.length === 0) {
continue;
}
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
if (resolvedUsers !== users) {
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
}
}
roomsConfig = nextRooms;
}
cfg = {
@@ -167,6 +213,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm,
allowFrom,
},
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixRoomConfig } from "./rooms.js";
describe("resolveMatrixRoomConfig", () => {
it("matches room IDs and aliases, not names", () => {
const rooms = {
"!room:example.org": { allow: true },
"#alias:example.org": { allow: true },
"Project Room": { allow: true },
};
const byId = resolveMatrixRoomConfig({
rooms,
roomId: "!room:example.org",
aliases: [],
name: "Project Room",
});
expect(byId.allowed).toBe(true);
expect(byId.matchKey).toBe("!room:example.org");
const byAlias = resolveMatrixRoomConfig({
rooms,
roomId: "!other:example.org",
aliases: ["#alias:example.org"],
name: "Other Room",
});
expect(byAlias.allowed).toBe(true);
expect(byAlias.matchKey).toBe("#alias:example.org");
const byName = resolveMatrixRoomConfig({
rooms: { "Project Room": { allow: true } },
roomId: "!different:example.org",
aliases: [],
name: "Project Room",
});
expect(byName.allowed).toBe(false);
expect(byName.config).toBeUndefined();
});
});

View File

@@ -22,7 +22,6 @@ export function resolveMatrixRoomConfig(params: {
params.roomId,
`room:${params.roomId}`,
...params.aliases,
params.name ?? "",
);
const {
entry: matched,

View File

@@ -0,0 +1,48 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
vi.mock("./directory-live.js", () => ({
listMatrixDirectoryPeersLive: vi.fn(),
listMatrixDirectoryGroupsLive: vi.fn(),
}));
describe("resolveMatrixTargets (users)", () => {
beforeEach(() => {
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
});
it("resolves exact unique display name matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("@alice:example.org");
});
it("does not resolve ambiguous or non-exact matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
{ kind: "user", id: "@alice:evil.example", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(false);
expect(result?.note).toMatch(/use full Matrix ID/i);
});
});

View File

@@ -28,6 +28,52 @@ function pickBestGroupMatch(
return matches[0];
}
function pickBestUserMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) {
return undefined;
}
const normalized = query.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const exact = matches.filter((match) => {
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
return normalized === id || normalized === name || normalized === handle;
});
if (exact.length === 1) {
return exact[0];
}
return undefined;
}
function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
if (matches.length === 0) {
return "no matches";
}
const normalized = query.trim().toLowerCase();
if (!normalized) {
return "empty input";
}
const exact = matches.filter((match) => {
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
return normalized === id || normalized === name || normalized === handle;
});
if (exact.length === 0) {
return "no exact match; use full Matrix ID";
}
if (exact.length > 1) {
return "multiple exact matches; use full Matrix ID";
}
return "no exact match; use full Matrix ID";
}
export async function resolveMatrixTargets(params: {
cfg: unknown;
inputs: string[];
@@ -52,13 +98,13 @@ export async function resolveMatrixTargets(params: {
query: trimmed,
limit: 5,
});
const best = matches[0];
const best = pickBestUserMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);

View File

@@ -7,7 +7,7 @@ export type MatrixDmConfig = {
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */
/** Allowlist for DM senders (matrix user IDs or "*"). */
allowFrom?: Array<string | number>;
};
@@ -22,7 +22,7 @@ export type MatrixRoomConfig = {
tools?: { allow?: string[]; deny?: string[] };
/** If true, reply without mention requirements. */
autoReply?: boolean;
/** Optional allowlist for room senders (user IDs or localparts). */
/** Optional allowlist for room senders (matrix user IDs). */
users?: Array<string | number>;
/** Optional skill filter for this room. */
skills?: string[];
@@ -61,7 +61,7 @@ export type MatrixConfig = {
allowlistOnly?: boolean;
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Allowlist for group senders (user IDs or localparts). */
/** Allowlist for group senders (matrix user IDs). */
groupAllowFrom?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
@@ -79,9 +79,9 @@ export type MatrixConfig = {
autoJoinAllowlist?: Array<string | number>;
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** Room config allowlist keyed by room ID, alias, or name. */
/** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
groups?: Record<string, MatrixRoomConfig>;
/** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
/** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
rooms?: Record<string, MatrixRoomConfig>;
/** Per-action tool gating (default: true for all). */
actions?: MatrixActionConfig;