fix: normalize pairing aliases and webhook guard (#991) (thanks @longmaba)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -178,9 +178,13 @@ export async function handleZaloWebhookRequest(
|
||||
}
|
||||
|
||||
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
|
||||
const raw = body.value as Record<string, unknown>;
|
||||
const raw = body.value;
|
||||
const record =
|
||||
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const update: ZaloUpdate | undefined =
|
||||
raw.ok === true && raw.result ? (raw.result as ZaloUpdate) : (raw as ZaloUpdate);
|
||||
record && record.ok === true && record.result
|
||||
? (record.result as ZaloUpdate)
|
||||
: (record as ZaloUpdate | null) ?? undefined;
|
||||
|
||||
if (!update?.event_name) {
|
||||
res.statusCode = 400;
|
||||
|
||||
70
extensions/zalo/src/monitor.webhook.test.ts
Normal file
70
extensions/zalo/src/monitor.webhook.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,16 @@ const pairingIdLabels: Record<string, string> = {
|
||||
telegram: "telegramUserId",
|
||||
discord: "discordUserId",
|
||||
};
|
||||
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 listPairingChannels = vi.fn(() => ["telegram", "discord", "imessage"]);
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
listChannelPairingRequests,
|
||||
@@ -24,6 +30,10 @@ vi.mock("../channels/plugins/pairing.js", () => ({
|
||||
getPairingAdapter,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
normalizeChannelId,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
@@ -63,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([
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
listPairingChannels,
|
||||
notifyPairingApproved,
|
||||
} 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 {
|
||||
@@ -17,9 +18,25 @@ const CHANNELS: PairingChannel[] = listPairingChannels();
|
||||
|
||||
/** Parse channel, allowing extension channels not in core registry. */
|
||||
function parseChannel(raw: unknown): PairingChannel {
|
||||
const value = String(raw ?? "").trim().toLowerCase();
|
||||
const value = (
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: typeof raw === "number" || typeof raw === "boolean"
|
||||
? String(raw)
|
||||
: ""
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!value) throw new Error("Channel required");
|
||||
if (CHANNELS.includes(value as PairingChannel)) return value as PairingChannel;
|
||||
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user