Files
openclaw/extensions/feishu/src/monitor.webhook-e2e.test.ts
heichl_xydigit c40a9b15b0 fix(feishu): guard Object.assign against prototype pollution in webhook payload merge (#111729)
* fix(feishu): guard Object.assign against prototype pollution in webhook payload merge

Object.assign merges JSON.parse webhook payload into an Object.create target
without filtering `__proto__`, `constructor`, or `prototype` keys. A
crafted webhook body like `{"__proto__": {"polluted": true}}` would set
the `__proto__` property on the envelope object, enabling prototype
pollution on the target.

Replace Object.assign with explicit iteration that skips blocked keys, and
use Object.create(null) so the envelope itself has no prototype to pollute.
Matches the isBlockedObjectKey pattern used in core config paths.

* fix(feishu): preserve webhook envelope when filtering payloads

* fix(feishu): preserve webhook envelope when filtering payloads

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-30 01:27:55 +08:00

650 lines
21 KiB
TypeScript

// Feishu tests cover monitor.webhook e2e plugin behavior.
import crypto from "node:crypto";
import type { Server } from "node:http";
import * as Lark from "@larksuiteoapi/node-sdk";
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js";
import {
buildWebhookConfig,
getFreePort,
waitUntilServerReady,
withRunningWebhookMonitor,
} from "./monitor.webhook.test-helpers.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
registerFeishuAiAgent: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
...actual,
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
};
});
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
import { cleanupFeishuMonitorStateForTests } from "./monitor.cleanup.test-helpers.js";
import { monitorFeishuProvider } from "./monitor.js";
import { httpServers } from "./monitor.state.js";
import { monitorWebhook } from "./monitor.transport.js";
import type { ResolvedFeishuAccount } from "./types.js";
beforeAll(async () => {
await import("./monitor.account.js");
});
function signFeishuPayload(params: {
encryptKey: string;
rawBody: string;
timestamp?: string;
nonce?: string;
}): Record<string, string> {
const timestamp = params.timestamp ?? "1711111111";
const nonce = params.nonce ?? "nonce-test";
const signature = crypto
.createHash("sha256")
.update(timestamp + nonce + params.encryptKey + params.rawBody)
.digest("hex");
return {
"content-type": "application/json",
"x-lark-request-timestamp": timestamp,
"x-lark-request-nonce": nonce,
"x-lark-signature": signature,
};
}
function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknown>): string {
const iv = crypto.randomBytes(16);
const key = crypto.createHash("sha256").update(encryptKey).digest();
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([iv, encrypted]).toString("base64");
}
async function postSignedPayload(url: string, payload: Record<string, unknown>) {
const rawBody = JSON.stringify(payload);
return await fetch(url, {
method: "POST",
headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }),
body: rawBody,
});
}
afterEach(() => {
cleanupFeishuMonitorStateForTests();
});
afterAll(() => {
vi.doUnmock("./probe.js");
vi.doUnmock("./client.js");
vi.doUnmock("./runtime.js");
vi.resetModules();
});
describe("Feishu webhook signed-request e2e", () => {
it("waits for HTTP close before resolving webhook abort cleanup", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const accountId = "abort-delayed-close";
const path = "/hook-e2e-abort-delayed-close";
const port = await getFreePort();
const abortController = new AbortController();
const monitorPromise = monitorFeishuProvider({
config: buildWebhookConfig({
accountId,
path,
port,
verificationToken: "verify_token",
encryptKey: "encrypt_key",
}),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
abortSignal: abortController.signal,
accountId,
});
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
const server = httpServers.get(accountId);
expect(server).toBeDefined();
if (!server) {
throw new Error("expected webhook server to be tracked");
}
const originalClose = server.close.bind(server);
let releaseClose: (() => void) | undefined;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const closeSpy = vi.fn((callback?: (err?: Error) => void) => {
void closeGate.then(() => {
originalClose(callback);
});
return server;
});
server.close = closeSpy as unknown as Server["close"];
let monitorSettled = false;
const observedMonitorPromise = monitorPromise.finally(() => {
monitorSettled = true;
});
try {
abortController.abort();
await vi.waitFor(() => {
expect(closeSpy).toHaveBeenCalledTimes(1);
});
expect(monitorSettled).toBe(false);
expect(httpServers.get(accountId)).toBe(server);
releaseClose?.();
await observedMonitorPromise;
expect(httpServers.has(accountId)).toBe(false);
} finally {
releaseClose?.();
}
});
it("rejects webhook monitor when abort cleanup close fails", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const accountId = "abort-close-fails";
const path = "/hook-e2e-abort-close-fails";
const port = await getFreePort();
const abortController = new AbortController();
const monitorPromise = monitorFeishuProvider({
config: buildWebhookConfig({
accountId,
path,
port,
verificationToken: "verify_token",
encryptKey: "encrypt_key",
}),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
abortSignal: abortController.signal,
accountId,
});
await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
const server = httpServers.get(accountId);
expect(server).toBeDefined();
if (!server) {
throw new Error("expected webhook server to be tracked");
}
const originalClose = server.close.bind(server);
server.close = vi.fn((callback?: (err?: Error) => void) => {
originalClose(() => {
callback?.(new Error("close failed"));
});
return server;
}) as unknown as Server["close"];
abortController.abort();
await expect(monitorPromise).rejects.toThrow("close failed");
expect(httpServers.has(accountId)).toBe(false);
});
it("rejects invalid signatures with 401 instead of empty 200", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "invalid-signature",
path: "/hook-e2e-invalid-signature",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = { type: "url_verification", challenge: "challenge-token" };
const rawBody = JSON.stringify(payload);
const response = await fetch(url, {
method: "POST",
headers: {
...signFeishuPayload({ encryptKey: "wrong_key", rawBody }),
},
body: rawBody,
});
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid signature");
},
);
});
it("rejects missing signature headers with 401", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "missing-signature",
path: "/hook-e2e-missing-signature",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "url_verification", challenge: "challenge-token" }),
});
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid signature");
},
);
});
it("rejects malformed short signatures with 401", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "short-signature",
path: "/hook-e2e-short-signature",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = { type: "url_verification", challenge: "challenge-token" };
const headers = signFeishuPayload({
encryptKey: "encrypt_key",
rawBody: JSON.stringify(payload),
});
headers["x-lark-signature"] = expectDefined(
headers["x-lark-signature"],
"Feishu webhook signature",
).slice(0, 12);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid signature");
},
);
});
it("returns 401 for unsigned invalid json before parsing", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "invalid-json",
path: "/hook-e2e-invalid-json",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not-json",
});
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid signature");
},
);
});
it("returns 400 for signed invalid json after signature validation", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "signed-invalid-json",
path: "/hook-e2e-signed-invalid-json",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const rawBody = "{not-json";
const response = await fetch(url, {
method: "POST",
headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }),
body: rawBody,
});
expect(response.status).toBe(400);
expect(await response.text()).toBe("Invalid JSON");
},
);
});
it("accepts signed plaintext url_verification challenges end-to-end", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "signed-challenge",
path: "/hook-e2e-signed-challenge",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = { type: "url_verification", challenge: "challenge-token" };
const response = await postSignedPayload(url, payload);
expect(response.status).toBe(200);
expect(response.headers.get("x-openclaw-delivery-accepted")).toBeNull();
await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" });
},
);
});
it("accepts signed non-challenge events and reaches the dispatcher", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "signed-dispatch",
path: "/hook-e2e-signed-dispatch",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
schema: "2.0",
header: { event_type: "unknown.event" },
event: {},
};
const response = await postSignedPayload(url, payload);
expect(response.status).toBe(200);
expect(response.headers.get("x-openclaw-delivery-accepted")).toBeNull();
expect(await response.text()).toContain("no unknown.event event handle");
},
);
});
it("marks durably admitted message acks with the delivery-accepted header", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "signed-durable-ack",
path: "/hook-e2e-durable-ack",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
schema: "2.0",
header: { event_type: "im.message.receive_v1", event_id: "evt-durable-ack-1" },
event: { message: { chat_id: "oc_durable_ack" } },
};
const response = await postSignedPayload(url, payload);
expect(response.status).toBe(200);
expect(response.headers.get("x-openclaw-delivery-accepted")).toBe("durable");
},
);
});
it("acks durable envelopes only after ingress admission resolves", async () => {
const accountId = "durable-ack-ordering";
const path = "/hook-e2e-durable-ack-ordering";
const port = await getFreePort();
const abortController = new AbortController();
let releaseAdmission: (() => void) | undefined;
const invoke = vi.fn(
async () =>
await new Promise<void>((resolve) => {
releaseAdmission = resolve;
}),
);
const monitorPromise = monitorWebhook({
account: {
accountId,
encryptKey: "encrypt_key",
config: {
enabled: true,
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: port,
webhookPath: path,
},
} as ResolvedFeishuAccount,
accountId,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
abortSignal: abortController.signal,
eventDispatcher: { invoke } as never,
invokeWebhookEvent: async () => {
await invoke();
return { kind: "durable", value: undefined };
},
});
try {
const url = `http://127.0.0.1:${port}${path}`;
await waitUntilServerReady(url);
const payload = {
schema: "2.0",
header: { event_type: "im.message.receive_v1", event_id: "evt-durable-ack-ordering-1" },
event: { message: { chat_id: "oc_durable_ack_ordering" } },
};
let acceptedResponseReceived = false;
const acceptedRequest = postSignedPayload(url, payload).then((response) => {
acceptedResponseReceived = true;
return response;
});
await vi.waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(1);
});
expect(acceptedResponseReceived).toBe(false);
if (!releaseAdmission) {
throw new Error("expected pending Feishu durable admission");
}
releaseAdmission();
const accepted = await acceptedRequest;
expect(accepted.status).toBe(200);
expect(accepted.headers.get("x-openclaw-delivery-accepted")).toBe("durable");
} finally {
releaseAdmission?.();
abortController.abort();
await monitorPromise;
}
});
it("does not mark acks when durable admission fails", async () => {
const accountId = "durable-ack-failure";
const path = "/hook-e2e-durable-ack-failure";
const port = await getFreePort();
const abortController = new AbortController();
const invoke = vi.fn(async () => {
throw new Error("admission failed");
});
const monitorPromise = monitorWebhook({
account: {
accountId,
encryptKey: "encrypt_key",
config: {
enabled: true,
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: port,
webhookPath: path,
},
} as ResolvedFeishuAccount,
accountId,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
abortSignal: abortController.signal,
eventDispatcher: { invoke } as never,
invokeWebhookEvent: async () => {
await invoke();
return { kind: "durable", value: undefined };
},
});
try {
const url = `http://127.0.0.1:${port}${path}`;
await waitUntilServerReady(url);
const response = await postSignedPayload(url, {
schema: "2.0",
header: { event_type: "im.message.receive_v1", event_id: "evt-durable-ack-failure-1" },
event: { message: { chat_id: "oc_durable_ack_failure" } },
});
expect(response.status).toBe(500);
expect(response.headers.get("x-openclaw-delivery-accepted")).toBeNull();
expect(invoke).toHaveBeenCalledTimes(1);
} finally {
abortController.abort();
await monitorPromise;
}
});
it("filters prototype-bearing keys without changing the Lark webhook envelope", async () => {
const accountId = "prototype-guard";
const path = "/hook-e2e-prototype-guard";
const port = await getFreePort();
const encryptKey = "encrypt_key";
const account = {
accountId,
encryptKey,
verificationToken: "verify_token",
config: {
enabled: true,
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: port,
webhookPath: path,
},
} as ResolvedFeishuAccount;
const handler = vi.fn(async () => ({ accepted: true }));
const dispatcher = new Lark.EventDispatcher({
encryptKey,
verificationToken: account.verificationToken,
});
dispatcher.register({ "test.prototype_guard": handler });
let observedEnvelope: Record<string, unknown> | undefined;
const invoke = dispatcher.invoke.bind(dispatcher);
const eventDispatcher = {
invoke: async (data: Record<string, unknown>, params?: { needCheck?: boolean }) => {
observedEnvelope = data;
return await invoke(data, params);
},
} as Lark.EventDispatcher;
const abortController = new AbortController();
const monitorPromise = monitorWebhook({
account,
accountId,
abortSignal: abortController.signal,
eventDispatcher,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
});
const url = `http://127.0.0.1:${port}${path}`;
await waitUntilServerReady(url);
const rawBody =
'{"schema":"2.0","header":{"event_type":"test.prototype_guard"},"event":{"safe":"kept"},"headers":{"x-envelope-marker":"forged"},"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true}}';
const headers = {
...signFeishuPayload({ encryptKey, rawBody }),
"x-envelope-marker": "preserved",
};
try {
const response = await fetch(url, { method: "POST", headers, body: rawBody });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ accepted: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(observedEnvelope).toBeDefined();
if (!observedEnvelope) {
throw new Error("expected Lark webhook envelope");
}
const envelopePrototype = Object.getPrototypeOf(observedEnvelope) as Record<string, unknown>;
expect(Object.hasOwn(observedEnvelope, "headers")).toBe(false);
expect(Object.hasOwn(envelopePrototype, "headers")).toBe(true);
expect(
(observedEnvelope.headers as Record<string, string | string[] | undefined>)[
"x-envelope-marker"
],
).toBe("preserved");
expect(observedEnvelope.event).toEqual({ safe: "kept" });
expect(observedEnvelope.polluted).toBeUndefined();
expect(Object.hasOwn(observedEnvelope, "__proto__")).toBe(false);
expect(Object.hasOwn(observedEnvelope, "constructor")).toBe(false);
expect(Object.hasOwn(observedEnvelope, "prototype")).toBe(false);
} finally {
abortController.abort();
await monitorPromise;
}
});
it("does not emit unhandled-event warning for bot_p2p_chat_entered_v1", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "p2p-chat-entered",
path: "/hook-e2e-p2p-chat-entered",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
schema: "2.0",
header: { event_type: "im.chat.access_event.bot_p2p_chat_entered_v1" },
event: {},
};
const response = await postSignedPayload(url, payload);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).not.toContain("no im.chat.access_event.bot_p2p_chat_entered_v1 event handle");
},
);
});
it("accepts signed encrypted url_verification challenges end-to-end", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "encrypted-challenge",
path: "/hook-e2e-encrypted-challenge",
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
encrypt: encryptFeishuPayload("encrypt_key", {
type: "url_verification",
challenge: "encrypted-challenge-token",
}),
};
const response = await postSignedPayload(url, payload);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
challenge: "encrypted-challenge-token",
});
},
);
});
});