diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e4925da..70230001f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc. - Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc. - Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc. +- Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind `channels.zalouser.dangerouslyAllowNameMatching`. Thanks @zpbrent. - Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc. - Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. - Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 9b62244e2..58bd2a439 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -86,11 +86,13 @@ Approve via: - Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. - Restrict to an allowlist with: - `channels.zalouser.groupPolicy = "allowlist"` - - `channels.zalouser.groups` (keys are group IDs or names; controls which groups are allowed) + - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible) - `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot) - Block all groups: `channels.zalouser.groupPolicy = "disabled"`. - The configure wizard can prompt for group allowlists. -- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping. +- Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. +- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching. - If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks. - Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 3084adf82..f7f6583d7 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -304,6 +304,7 @@ schema: - `channels.googlechat.dangerouslyAllowNameMatching` - `channels.googlechat.accounts..dangerouslyAllowNameMatching` - `channels.msteams.dangerouslyAllowNameMatching` +- `channels.zalouser.dangerouslyAllowNameMatching` (extension channel) - `channels.irc.dangerouslyAllowNameMatching` (extension channel) - `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel) - `channels.mattermost.dangerouslyAllowNameMatching` (extension channel) diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 79e3ae747..6f2badf91 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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), }), ); } diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 4879a2d46..1ff115876 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -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(), diff --git a/extensions/zalouser/src/group-policy.test.ts b/extensions/zalouser/src/group-policy.test.ts index 0ab0e01d7..adbeffbe8 100644 --- a/extensions/zalouser/src/group-policy.test.ts +++ b/extensions/zalouser/src/group-policy.test.ts @@ -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 }, diff --git a/extensions/zalouser/src/group-policy.ts b/extensions/zalouser/src/group-policy.ts index 1b6ca8e20..c4c1afe4f 100644 --- a/extensions/zalouser/src/group-policy.ts +++ b/extensions/zalouser/src/group-policy.ts @@ -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(); 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("*"); diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 49593f070..f6723cad3 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -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({ diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 5329b22fa..f004df481 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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; + 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); diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e6343b1f6..08dc2fd8d 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -97,6 +97,7 @@ type ZalouserSharedConfig = { enabled?: boolean; name?: string; profile?: string; + dangerouslyAllowNameMatching?: boolean; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; historyLimit?: number;