Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
f0961d3b82 fix: normalize pairing aliases and webhook guard (#991) (thanks @longmaba) 2026-01-16 04:53:40 +00:00
Long
dbd4481bb1 fix(zalo): fix pairing channel detection and webhook payload format
Amp-Thread-ID: https://ampcode.com/threads/T-019bc4e0-fcb1-77be-b0b5-0d498f0c7197
Co-authored-by: Amp <amp@ampcode.com>
2026-01-16 04:46:42 +00:00
9 changed files with 175 additions and 24 deletions

View File

@ -36,6 +36,7 @@
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
## 2026.1.14-1

View File

@ -30,6 +30,11 @@ export type CoreChannelDeps = {
channel: string;
id: string;
meta?: { name?: string };
pairingAdapter?: {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
};
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (

View File

@ -12,6 +12,7 @@ import {
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import { zaloPlugin } from "./channel.js";
import { loadCoreChannelDeps } from "./core-bridge.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import type { CoreConfig } from "./types.js";
@ -176,8 +177,16 @@ export async function handleZaloWebhookRequest(
return true;
}
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
if (!payload?.ok || !payload.result) {
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
const raw = body.value;
const record =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const update: ZaloUpdate | undefined =
record && record.ok === true && record.result
? (record.result as ZaloUpdate)
: (record as ZaloUpdate | null) ?? undefined;
if (!update?.event_name) {
res.statusCode = 400;
res.end("invalid payload");
return true;
@ -185,7 +194,7 @@ export async function handleZaloWebhookRequest(
target.statusSink?.({ lastInboundAt: Date.now() });
processUpdate(
payload.result,
update,
target.token,
target.account,
target.config,
@ -445,6 +454,7 @@ async function processMessageWithPipeline(params: {
channel: "zalo",
id: senderId,
meta: { name: senderName ?? undefined },
pairingAdapter: zaloPlugin.pairing,
});
if (created) {

View File

@ -0,0 +1,70 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import type { CoreConfig, ResolvedZaloAccount } from "./types.js";
import type { loadCoreChannelDeps } from "./core-bridge.js";
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
async function withServer(
handler: Parameters<typeof createServer>[0],
fn: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo | null;
if (!address) throw new Error("missing server address");
try {
await fn(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
describe("handleZaloWebhookRequest", () => {
it("returns 400 for non-object payloads", async () => {
const deps = {} as Awaited<ReturnType<typeof loadCoreChannelDeps>>;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
token: "tok",
tokenSource: "config",
config: {},
};
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as CoreConfig,
runtime: {},
deps,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,
});
try {
await withServer(async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
}, async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
},
body: "null",
});
expect(response.status).toBe(400);
});
} finally {
unregister();
}
});
});

View File

@ -53,8 +53,11 @@ export async function notifyPairingApproved(params: {
id: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
}): Promise<void> {
const adapter = requirePairingAdapter(params.channelId);
// Extensions may provide adapter directly to bypass ESM module isolation
const adapter = params.pairingAdapter ?? requirePairingAdapter(params.channelId);
if (!adapter.notifyApproval) return;
await adapter.notifyApproval({
cfg: params.cfg,

View File

@ -8,11 +8,16 @@ const pairingIdLabels: Record<string, string> = {
telegram: "telegramUserId",
discord: "discordUserId",
};
const requirePairingAdapter = vi.fn((channel: string) => ({
const normalizeChannelId = vi.fn((raw: string) => {
if (!raw) return null;
if (raw === "imsg") return "imessage";
if (["telegram", "discord", "imessage"].includes(raw)) return raw;
return null;
});
const getPairingAdapter = vi.fn((channel: string) => ({
idLabel: pairingIdLabels[channel] ?? "userId",
}));
const listPairingChannels = vi.fn(() => ["telegram", "discord"]);
const resolvePairingChannel = vi.fn((raw: string) => raw);
const listPairingChannels = vi.fn(() => ["telegram", "discord", "imessage"]);
vi.mock("../pairing/pairing-store.js", () => ({
listChannelPairingRequests,
@ -21,9 +26,12 @@ vi.mock("../pairing/pairing-store.js", () => ({
vi.mock("../channels/plugins/pairing.js", () => ({
listPairingChannels,
resolvePairingChannel,
notifyPairingApproved,
requirePairingAdapter,
getPairingAdapter,
}));
vi.mock("../channels/plugins/index.js", () => ({
normalizeChannelId,
}));
vi.mock("../config/config.js", () => ({
@ -65,6 +73,32 @@ describe("pairing cli", () => {
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
});
it("normalizes channel aliases", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "imsg"], { from: "user" });
expect(normalizeChannelId).toHaveBeenCalledWith("imsg");
expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage");
});
it("accepts extension channels outside the registry", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "zalo"], { from: "user" });
expect(normalizeChannelId).toHaveBeenCalledWith("zalo");
expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo");
});
it("labels Discord ids as discordUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([

View File

@ -2,8 +2,8 @@ import type { Command } from "commander";
import {
listPairingChannels,
notifyPairingApproved,
resolvePairingChannel,
} from "../channels/plugins/pairing.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { loadConfig } from "../config/config.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
import {
@ -16,8 +16,30 @@ import { theme } from "../terminal/theme.js";
const CHANNELS: PairingChannel[] = listPairingChannels();
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown): PairingChannel {
return resolvePairingChannel(raw);
const value = (
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "boolean"
? String(raw)
: ""
)
.trim()
.toLowerCase();
if (!value) throw new Error("Channel required");
const normalized = normalizeChannelId(value);
if (normalized) {
if (!CHANNELS.includes(normalized as PairingChannel)) {
throw new Error(`Channel ${normalized} does not support pairing`);
}
return normalized as PairingChannel;
}
// Allow extension channels: validate format but don't require registry
if (/^[a-z][a-z0-9_-]{0,63}$/.test(value)) return value as PairingChannel;
throw new Error(`Invalid channel: ${value}`);
}
async function notifyApproved(channel: PairingChannel, id: string) {

View File

@ -1,6 +1,6 @@
import { requirePairingAdapter } from "../channels/plugins/pairing.js";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { PairingChannel } from "./pairing-store.js";
export function resolvePairingIdLabel(channel: PairingChannel): string {
return requirePairingAdapter(channel).idLabel;
return getPairingAdapter(channel)?.idLabel ?? "userId";
}

View File

@ -4,8 +4,8 @@ import os from "node:os";
import path from "node:path";
import lockfile from "proper-lockfile";
import { requirePairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
const PAIRING_CODE_LENGTH = 8;
@ -48,15 +48,24 @@ function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
return resolveOAuthDir(env, stateDir);
}
/** Sanitize channel ID for use in filenames (prevent path traversal). */
function safeChannelKey(channel: PairingChannel): string {
const raw = String(channel).trim().toLowerCase();
if (!raw) throw new Error("invalid pairing channel");
const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid pairing channel");
return safe;
}
function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveCredentialsDir(env), `${channel}-pairing.json`);
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`);
}
function resolveAllowFromPath(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveCredentialsDir(env), `${channel}-allowFrom.json`);
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-allowFrom.json`);
}
function safeParseJson<T>(raw: string): T | null {
@ -184,11 +193,11 @@ function normalizeId(value: string | number): string {
}
function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
const adapter = requirePairingAdapter(channel);
const trimmed = entry.trim();
if (!trimmed) return "";
if (trimmed === "*") return "";
const normalized = adapter.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
const adapter = getPairingAdapter(channel);
const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
return String(normalized).trim();
}
@ -196,7 +205,6 @@ export async function readChannelAllowFromStore(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
requirePairingAdapter(channel);
const filePath = resolveAllowFromPath(channel, env);
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
@ -211,7 +219,6 @@ export async function addChannelAllowFromStoreEntry(params: {
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
@ -242,7 +249,6 @@ export async function listChannelPairingRequests(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<PairingRequest[]> {
requirePairingAdapter(channel);
const filePath = resolvePairingPath(channel, env);
return await withFileLock(
filePath,
@ -287,8 +293,9 @@ export async function upsertChannelPairingRequest(params: {
id: string | number;
meta?: Record<string, string | undefined | null>;
env?: NodeJS.ProcessEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ code: string; created: boolean }> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.channel, env);
return await withFileLock(
@ -383,7 +390,6 @@ export async function approveChannelPairingCode(params: {
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ id: string; entry?: PairingRequest } | null> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const code = params.code.trim().toUpperCase();
if (!code) return null;