From b4010a0b627025c809c0e5dbdbd4770f3bc59ef8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 23:30:05 +0000 Subject: [PATCH] fix(zalo): enforce group sender policy in groups --- CHANGELOG.md | 1 + docs/channels/groups.md | 6 +- docs/channels/zalo.md | 38 ++++-- extensions/zalo/src/channel.ts | 31 ++++- extensions/zalo/src/config-schema.ts | 2 + .../zalo/src/monitor.group-policy.test.ts | 106 ++++++++++++++++ extensions/zalo/src/monitor.ts | 113 ++++++++++++++++++ extensions/zalo/src/types.ts | 4 + 8 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 extensions/zalo/src/monitor.group-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 914f5db6c97..6cf8e3f9fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index de848243c9c..8b8af64b94c 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -1,5 +1,5 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams/Zalo)" read_when: - Changing group chat behavior or mention gating title: "Groups" @@ -7,7 +7,7 @@ title: "Groups" # Groups -OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. +OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams, Zalo. ## Beginner intro (2 minutes) @@ -183,7 +183,7 @@ Control how group/room messages are handled per channel: Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord: allowlist uses `channels.discord.guilds..channels`. - Slack: allowlist uses `channels.slack.channels`. - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cda126f5649..8e5d8ab0382 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. Direct messages only; groups coming soon per Zalo docs. +Status: experimental. DMs are supported; group handling is available with explicit group policy controls. ## Plugin required @@ -51,7 +51,7 @@ It is a good fit for support or notifications where you want deterministic routi - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are not yet supported (Zalo docs state "coming soon"). +- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. ## Setup (fast path) @@ -107,6 +107,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and - Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available). +## Access control (Groups) + +- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. +- Default behavior is fail-closed: `allowlist`. +- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. +- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. +- `groupPolicy: "disabled"` blocks all group messages. +- `groupPolicy: "open"` allows any group member (mention-gated). +- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -130,16 +140,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Capabilities -| Feature | Status | -| --------------- | ------------------------------ | -| Direct messages | ✅ Supported | -| Groups | ❌ Coming soon (per Zalo docs) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +| Feature | Status | +| --------------- | -------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ⚠️ Supported with policy controls (allowlist by default) | +| Media (images) | ✅ Supported | +| Reactions | ❌ Not supported | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -172,6 +182,8 @@ Provider options: - `channels.zalo.tokenFile`: read token from file path. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). - `channels.zalo.webhookSecret`: webhook secret (8-256 chars). @@ -186,6 +198,8 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. - `channels.zalo.accounts..webhookPath`: per-account webhook path. diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 9e263f0bff8..34706e16882 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -16,6 +16,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -56,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { export const zaloDock: ChannelDock = { id: "zalo", capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, @@ -82,7 +84,7 @@ export const zaloPlugin: ChannelPlugin = { meta, onboarding: zaloOnboardingAdapter, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, @@ -143,6 +145,31 @@ export const zaloPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }; }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.zalo !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => + String(entry), + ); + const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`, + ]; + }, }, groups: { resolveRequireMention: () => true, diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index db4fba27814..a38a0a1cbfd 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({ webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts new file mode 100644 index 00000000000..2ce0b1be2a2 --- /dev/null +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./monitor.js"; + +describe("zalo group policy access", () => { + it("defaults missing provider config to allowlist", () => { + const resolved = __testing.resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: undefined, + defaultGroupPolicy: "open", + }); + expect(resolved).toEqual({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + }); + }); + + it("blocks all group messages when policy is disabled", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:123"], + senderId: "123", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "disabled", + reason: "disabled", + }); + }); + + it("blocks group messages on allowlist policy with empty allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "attacker", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "empty_allowlist", + }); + }); + + it("blocks sender not in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:victim-user-001"], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "sender_not_allowlisted", + }); + }); + + it("allows sender in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows any sender with wildcard allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["*"], + senderId: "random-user", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows all group senders on open policy", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "open", + defaultGroupPolicy: "allowlist", + groupAllowFrom: [], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "open", + reason: "allowed", + }); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 47269635a44..71b3e4a1551 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -10,9 +10,12 @@ import { resolveSingleWebhookTarget, resolveSenderCommandAuthorization, resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, sendMediaWithLeadingCaption, resolveWebhookPath, resolveWebhookTargets, + warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -62,6 +65,14 @@ const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; type ZaloCoreRuntime = ReturnType; type WebhookRateLimitState = { count: number; windowStartMs: number }; +type ZaloGroupPolicy = "open" | "allowlist" | "disabled"; +type ZaloGroupAccessReason = "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted"; +type ZaloGroupAccessDecision = { + allowed: boolean; + groupPolicy: ZaloGroupPolicy; + providerMissingFallbackApplied: boolean; + reason: ZaloGroupAccessReason; +}; function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { @@ -80,6 +91,67 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { }); } +function resolveZaloRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: ZaloGroupPolicy; + defaultGroupPolicy?: ZaloGroupPolicy; +}): { + groupPolicy: ZaloGroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + +function evaluateZaloGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: ZaloGroupPolicy; + defaultGroupPolicy?: ZaloGroupPolicy; + groupAllowFrom: string[]; + senderId: string; +}): ZaloGroupAccessDecision { + const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); + if (groupPolicy === "disabled") { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "disabled", + }; + } + if (groupPolicy === "allowlist") { + if (params.groupAllowFrom.length === 0) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "empty_allowlist", + }; + } + if (!isSenderAllowed(params.senderId, params.groupAllowFrom)) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "sender_not_allowlisted", + }; + } + } + return { + allowed: true, + groupPolicy, + providerMissingFallbackApplied, + reason: "allowed", + }; +} + type WebhookTarget = { token: string; account: ResolvedZaloAccount; @@ -502,6 +574,42 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); + const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); + const groupAllowFrom = + configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const groupAccess = isGroup + ? evaluateZaloGroupAccess({ + providerConfigPresent: config.channels?.zalo !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + groupAllowFrom, + senderId, + }) + : undefined; + if (groupAccess) { + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied, + providerKey: "zalo", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); + if (!groupAccess.allowed) { + if (groupAccess.reason === "disabled") { + logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`); + } else if (groupAccess.reason === "empty_allowlist") { + logVerbose( + core, + runtime, + `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`, + ); + } else if (groupAccess.reason === "sender_not_allowlisted") { + logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`); + } + return; + } + } + const rawBody = text?.trim() || (mediaPath ? "" : ""); const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ cfg: config, @@ -818,3 +926,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< return { stop }; } + +export const __testing = { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +}; diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index bcc43138f97..c17ea0cfc61 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -17,6 +17,10 @@ export type ZaloAccountConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; /** Allowlist for DM senders (Zalo user IDs). */ allowFrom?: Array; + /** Group-message access policy. */ + groupPolicy?: "open" | "allowlist" | "disabled"; + /** Allowlist for group senders (falls back to allowFrom when unset). */ + groupAllowFrom?: Array; /** Max inbound media size in MB. */ mediaMaxMb?: number; /** Proxy URL for API requests. */