fix(zalouser): require ids for group allowlist auth

This commit is contained in:
Peter Steinberger
2026-03-13 01:31:08 +00:00
parent c80da4e72f
commit b14a5c6713
10 changed files with 112 additions and 7 deletions

View File

@@ -37,7 +37,11 @@ import {
type ResolvedZalouserAccount,
} from "./accounts.js";
import { ZalouserConfigSchema } from "./config-schema.js";
import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
import {
buildZalouserGroupCandidates,
findZalouserGroupEntry,
isZalouserDangerousNameMatchingEnabled,
} from "./group-policy.js";
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
@@ -216,6 +220,7 @@ function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
allowNameMatching: isZalouserDangerousNameMatchingEnabled(account.config),
}),
);
}

View File

@@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),

View File

@@ -23,6 +23,18 @@ describe("zalouser group policy helpers", () => {
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
});
it("builds id-only candidates when name matching is disabled", () => {
expect(
buildZalouserGroupCandidates({
groupId: "123",
groupChannel: "chan-1",
groupName: "Team Alpha",
includeGroupIdAlias: true,
allowNameMatching: false,
}),
).toEqual(["123", "group:123", "*"]);
});
it("finds the first matching group entry", () => {
const groups = {
"group:123": { allow: true },

View File

@@ -17,12 +17,19 @@ export function normalizeZalouserGroupSlug(raw?: string | null): string {
.replace(/^-+|-+$/g, "");
}
export function isZalouserDangerousNameMatchingEnabled(params: {
dangerouslyAllowNameMatching?: boolean;
}): boolean {
return params.dangerouslyAllowNameMatching === true;
}
export function buildZalouserGroupCandidates(params: {
groupId?: string | null;
groupChannel?: string | null;
groupName?: string | null;
includeGroupIdAlias?: boolean;
includeWildcard?: boolean;
allowNameMatching?: boolean;
}): string[] {
const seen = new Set<string>();
const out: string[] = [];
@@ -43,10 +50,12 @@ export function buildZalouserGroupCandidates(params: {
if (params.includeGroupIdAlias === true && groupId) {
push(`group:${groupId}`);
}
push(groupChannel);
push(groupName);
if (groupName) {
push(normalizeZalouserGroupSlug(groupName));
if (params.allowNameMatching !== false) {
push(groupChannel);
push(groupName);
if (groupName) {
push(normalizeZalouserGroupSlug(groupName));
}
}
if (params.includeWildcard !== false) {
push("*");

View File

@@ -424,6 +424,73 @@ describe("zalouser monitor group mention gating", () => {
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not accept a different group id by matching only the mutable group name by default", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
dangerouslyAllowNameMatching: true,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
});
it("allows group control commands when sender is in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installRuntime({

View File

@@ -33,6 +33,7 @@ import {
import {
buildZalouserGroupCandidates,
findZalouserGroupEntry,
isZalouserDangerousNameMatchingEnabled,
isZalouserGroupEntryAllowed,
} from "./group-policy.js";
import { formatZalouserMessageSidFull, resolveZalouserMessageSid } from "./message-sid.js";
@@ -212,6 +213,7 @@ function resolveGroupRequireMention(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
allowNameMatching?: boolean;
}): boolean {
const entry = findZalouserGroupEntry(
params.groups ?? {},
@@ -220,6 +222,7 @@ function resolveGroupRequireMention(params: {
groupName: params.groupName,
includeGroupIdAlias: true,
includeWildcard: true,
allowNameMatching: params.allowNameMatching,
}),
);
if (typeof entry?.requireMention === "boolean") {
@@ -316,6 +319,7 @@ async function processMessage(
});
const groups = account.config.groups ?? {};
const allowNameMatching = isZalouserDangerousNameMatchingEnabled(account.config);
if (isGroup) {
const groupEntry = findZalouserGroupEntry(
groups,
@@ -324,6 +328,7 @@ async function processMessage(
groupName,
includeGroupIdAlias: true,
includeWildcard: true,
allowNameMatching,
}),
);
const routeAccess = evaluateGroupRouteAccessForPolicy({
@@ -466,6 +471,7 @@ async function processMessage(
groupId: chatId,
groupName,
groups,
allowNameMatching,
})
: false;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);

View File

@@ -97,6 +97,7 @@ type ZalouserSharedConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dangerouslyAllowNameMatching?: boolean;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
historyLimit?: number;