fix(zalo): enforce group sender policy in groups

This commit is contained in:
Peter Steinberger
2026-02-24 23:30:05 +00:00
parent 4355e08262
commit b4010a0b62
8 changed files with 284 additions and 17 deletions

View File

@@ -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.

View File

@@ -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.<id>.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.

View File

@@ -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.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
- `channels.zalo.accounts.<id>.groupAllowFrom`: per-account group sender allowlist.
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
- `channels.zalo.accounts.<id>.webhookPath`: per-account webhook path.

View File

@@ -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<ResolvedZaloAccount> = {
meta,
onboarding: zaloOnboardingAdapter,
capabilities: {
chatTypes: ["direct"],
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
@@ -143,6 +145,31 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
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,

View File

@@ -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(),

View File

@@ -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",
});
});
});

View File

@@ -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<typeof getZaloRuntime>;
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 ? "<media:image>" : "");
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,
};

View File

@@ -17,6 +17,10 @@ export type ZaloAccountConfig = {
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
/** Allowlist for DM senders (Zalo user IDs). */
allowFrom?: Array<string | number>;
/** Group-message access policy. */
groupPolicy?: "open" | "allowlist" | "disabled";
/** Allowlist for group senders (falls back to allowFrom when unset). */
groupAllowFrom?: Array<string | number>;
/** Max inbound media size in MB. */
mediaMaxMb?: number;
/** Proxy URL for API requests. */