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

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