fix(zalouser): require ids for group allowlist auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -304,6 +304,7 @@ schema:
|
||||
- `channels.googlechat.dangerouslyAllowNameMatching`
|
||||
- `channels.googlechat.accounts.<accountId>.dangerouslyAllowNameMatching`
|
||||
- `channels.msteams.dangerouslyAllowNameMatching`
|
||||
- `channels.zalouser.dangerouslyAllowNameMatching` (extension channel)
|
||||
- `channels.irc.dangerouslyAllowNameMatching` (extension channel)
|
||||
- `channels.irc.accounts.<accountId>.dangerouslyAllowNameMatching` (extension channel)
|
||||
- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel)
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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("*");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user