mirror of https://github.com/openclaw/openclaw.git
571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
import type { RequestListener } from "node:http";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
|
import { withServer } from "../../../test/helpers/http-test-server.js";
|
|
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
|
import {
|
|
clearZaloWebhookSecurityStateForTest,
|
|
getZaloWebhookRateLimitStateSizeForTest,
|
|
getZaloWebhookStatusCounterSizeForTest,
|
|
handleZaloWebhookRequest,
|
|
registerZaloWebhookTarget,
|
|
} from "./monitor.js";
|
|
import type { ResolvedZaloAccount } from "./types.js";
|
|
|
|
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
|
|
accountId: "default",
|
|
enabled: true,
|
|
token: "tok",
|
|
tokenSource: "config",
|
|
config: {},
|
|
};
|
|
|
|
const webhookRequestHandler: RequestListener = async (req, res) => {
|
|
const handled = await handleZaloWebhookRequest(req, res);
|
|
if (!handled) {
|
|
res.statusCode = 404;
|
|
res.end("not found");
|
|
}
|
|
};
|
|
|
|
function registerTarget(params: {
|
|
path: string;
|
|
secret?: string;
|
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
account?: ResolvedZaloAccount;
|
|
config?: OpenClawConfig;
|
|
core?: PluginRuntime;
|
|
}): () => void {
|
|
return registerZaloWebhookTarget({
|
|
token: "tok",
|
|
account: params.account ?? DEFAULT_ACCOUNT,
|
|
config: params.config ?? ({} as OpenClawConfig),
|
|
runtime: {},
|
|
core: params.core ?? ({} as PluginRuntime),
|
|
secret: params.secret ?? "secret",
|
|
path: params.path,
|
|
mediaMaxMb: 5,
|
|
statusSink: params.statusSink,
|
|
});
|
|
}
|
|
|
|
function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
|
|
core: PluginRuntime;
|
|
readAllowFromStore: ReturnType<typeof vi.fn>;
|
|
upsertPairingRequest: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
|
|
const upsertPairingRequest = vi
|
|
.fn()
|
|
.mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
|
|
const core = {
|
|
logging: {
|
|
shouldLogVerbose: () => false,
|
|
},
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore,
|
|
upsertPairingRequest,
|
|
buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
|
|
},
|
|
commands: {
|
|
shouldComputeCommandAuthorized: vi.fn(() => false),
|
|
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
},
|
|
},
|
|
} as unknown as PluginRuntime;
|
|
return { core, readAllowFromStore, upsertPairingRequest };
|
|
}
|
|
|
|
async function postUntilRateLimited(params: {
|
|
baseUrl: string;
|
|
path: string;
|
|
secret: string;
|
|
withNonceQuery?: boolean;
|
|
attempts?: number;
|
|
}): Promise<boolean> {
|
|
const attempts = params.attempts ?? 130;
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
const url = params.withNonceQuery
|
|
? `${params.baseUrl}${params.path}?nonce=${i}`
|
|
: `${params.baseUrl}${params.path}`;
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": params.secret,
|
|
"content-type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
if (response.status === 429) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
describe("handleZaloWebhookRequest", () => {
|
|
afterEach(() => {
|
|
clearZaloWebhookSecurityStateForTest();
|
|
setActivePluginRegistry(createEmptyPluginRegistry());
|
|
});
|
|
|
|
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
|
const registry = createEmptyPluginRegistry();
|
|
setActivePluginRegistry(registry);
|
|
const unregisterA = registerTarget({ path: "/hook" });
|
|
const unregisterB = registerTarget({ path: "/hook" });
|
|
|
|
expect(registry.httpRoutes).toHaveLength(1);
|
|
expect(registry.httpRoutes[0]).toEqual(
|
|
expect.objectContaining({
|
|
pluginId: "zalo",
|
|
path: "/hook",
|
|
source: "zalo-webhook",
|
|
}),
|
|
);
|
|
|
|
unregisterA();
|
|
expect(registry.httpRoutes).toHaveLength(1);
|
|
unregisterB();
|
|
expect(registry.httpRoutes).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 400 for non-object payloads", async () => {
|
|
const unregister = registerTarget({ path: "/hook" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: "null",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.text()).toBe("Bad Request");
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("rejects ambiguous routing when multiple targets match the same secret", async () => {
|
|
const sinkA = vi.fn();
|
|
const sinkB = vi.fn();
|
|
const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
|
|
const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(sinkA).not.toHaveBeenCalled();
|
|
expect(sinkB).not.toHaveBeenCalled();
|
|
});
|
|
} finally {
|
|
unregisterA();
|
|
unregisterB();
|
|
}
|
|
});
|
|
|
|
it("returns 415 for non-json content-type", async () => {
|
|
const unregister = registerTarget({ path: "/hook-content-type" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook-content-type`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "text/plain",
|
|
},
|
|
body: "{}",
|
|
});
|
|
|
|
expect(response.status).toBe(415);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("deduplicates webhook replay by event_name + message_id", async () => {
|
|
const sink = vi.fn();
|
|
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
|
|
|
|
const payload = {
|
|
event_name: "message.text.received",
|
|
message: {
|
|
from: { id: "123" },
|
|
chat: { id: "123", chat_type: "PRIVATE" },
|
|
message_id: "msg-replay-1",
|
|
date: Math.floor(Date.now() / 1000),
|
|
text: "hello",
|
|
},
|
|
};
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const first = await fetch(`${baseUrl}/hook-replay`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const second = await fetch(`${baseUrl}/hook-replay`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(second.status).toBe(200);
|
|
expect(sink).toHaveBeenCalledTimes(1);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
|
|
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
|
const recordInboundSessionMock = vi.fn(async () => undefined);
|
|
const fetchRemoteMediaMock = vi.fn(async () => ({
|
|
buffer: Buffer.from("image-bytes"),
|
|
contentType: "image/jpeg",
|
|
}));
|
|
const saveMediaBufferMock = vi.fn(async () => ({
|
|
path: "/tmp/zalo-photo.jpg",
|
|
contentType: "image/jpeg",
|
|
}));
|
|
const core = createPluginRuntimeMock({
|
|
channel: {
|
|
media: {
|
|
fetchRemoteMedia:
|
|
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
|
saveMediaBuffer:
|
|
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
},
|
|
reply: {
|
|
finalizeInboundContext:
|
|
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
|
async () => undefined,
|
|
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
|
},
|
|
session: {
|
|
recordInboundSession:
|
|
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
|
},
|
|
commands: {
|
|
shouldComputeCommandAuthorized: vi.fn(
|
|
() => false,
|
|
) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
|
resolveCommandAuthorizedFromAuthorizers: vi.fn(
|
|
() => false,
|
|
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
|
isControlCommandMessage: vi.fn(
|
|
() => false,
|
|
) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
|
},
|
|
},
|
|
});
|
|
const unregister = registerTarget({
|
|
path: "/hook-image",
|
|
core,
|
|
account: {
|
|
...DEFAULT_ACCOUNT,
|
|
config: {
|
|
dmPolicy: "open",
|
|
},
|
|
},
|
|
});
|
|
|
|
const payload = {
|
|
event_name: "message.image.received",
|
|
message: {
|
|
date: 1774086023728,
|
|
chat: { chat_type: "PRIVATE", id: "chat-123" },
|
|
caption: "",
|
|
message_id: "msg-123",
|
|
message_type: "CHAT_PHOTO",
|
|
from: { id: "user-123", is_bot: false, display_name: "Test User" },
|
|
photo_url: "https://example.com/test-image.jpg",
|
|
},
|
|
};
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook-image`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
|
|
await vi.waitFor(() =>
|
|
expect(fetchRemoteMediaMock).toHaveBeenCalledWith({
|
|
url: "https://example.com/test-image.jpg",
|
|
maxBytes: 5 * 1024 * 1024,
|
|
}),
|
|
);
|
|
expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
|
|
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
SenderName: "Test User",
|
|
MediaPath: "/tmp/zalo-photo.jpg",
|
|
MediaType: "image/jpeg",
|
|
}),
|
|
);
|
|
expect(recordInboundSessionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ctx: expect.objectContaining({
|
|
SenderName: "Test User",
|
|
MediaPath: "/tmp/zalo-photo.jpg",
|
|
MediaType: "image/jpeg",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns 429 when per-path request rate exceeds threshold", async () => {
|
|
const unregister = registerTarget({ path: "/hook-rate" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const saw429 = await postUntilRateLimited({
|
|
baseUrl,
|
|
path: "/hook-rate",
|
|
secret: "secret", // pragma: allowlist secret
|
|
});
|
|
|
|
expect(saw429).toBe(true);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
|
|
const unregister = registerTarget({ path: "/hook-query-status" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
let saw429 = false;
|
|
for (let i = 0; i < 200; i += 1) {
|
|
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
"content-type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
expect([401, 429]).toContain(response.status);
|
|
if (response.status === 429) {
|
|
saw429 = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(saw429).toBe(true);
|
|
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("rate limits authenticated requests even when query strings churn", async () => {
|
|
const unregister = registerTarget({ path: "/hook-query-rate" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const saw429 = await postUntilRateLimited({
|
|
baseUrl,
|
|
path: "/hook-query-rate",
|
|
secret: "secret", // pragma: allowlist secret
|
|
withNonceQuery: true,
|
|
});
|
|
|
|
expect(saw429).toBe(true);
|
|
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
|
|
const unregister = registerTarget({ path: "/hook-preauth-rate" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const saw429 = await postUntilRateLimited({
|
|
baseUrl,
|
|
path: "/hook-preauth-rate",
|
|
secret: "invalid-token", // pragma: allowlist secret
|
|
withNonceQuery: true,
|
|
});
|
|
|
|
expect(saw429).toBe(true);
|
|
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
|
|
const unregister = registerTarget({
|
|
path: "/hook-preauth-split",
|
|
config: {
|
|
gateway: {
|
|
trustedProxies: ["127.0.0.1"],
|
|
},
|
|
} as OpenClawConfig,
|
|
});
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
for (let i = 0; i < 130; i += 1) {
|
|
const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
"content-type": "application/json",
|
|
"x-forwarded-for": "203.0.113.10",
|
|
},
|
|
body: "{}",
|
|
});
|
|
if (response.status === 429) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
"x-forwarded-for": "198.51.100.20",
|
|
},
|
|
body: JSON.stringify({ event_name: "message.unsupported.received" }),
|
|
});
|
|
|
|
expect(validResponse.status).toBe(200);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
|
|
const unregister = registerTarget({ path: "/hook-auth-before-type" });
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
"content-type": "text/plain",
|
|
},
|
|
body: "not-json",
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
});
|
|
|
|
it("scopes DM pairing store reads and writes to accountId", async () => {
|
|
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
|
|
pairingCreated: false,
|
|
});
|
|
const account: ResolvedZaloAccount = {
|
|
...DEFAULT_ACCOUNT,
|
|
accountId: "work",
|
|
config: {
|
|
dmPolicy: "pairing",
|
|
allowFrom: [],
|
|
},
|
|
};
|
|
const unregister = registerTarget({
|
|
path: "/hook-account-scope",
|
|
account,
|
|
core,
|
|
});
|
|
|
|
const payload = {
|
|
event_name: "message.text.received",
|
|
message: {
|
|
from: { id: "123", name: "Attacker" },
|
|
chat: { id: "dm-work", chat_type: "PRIVATE" },
|
|
message_id: "msg-work-1",
|
|
date: Math.floor(Date.now() / 1000),
|
|
text: "hello",
|
|
},
|
|
};
|
|
|
|
try {
|
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
const response = await fetch(`${baseUrl}/hook-account-scope`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-bot-api-secret-token": "secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
} finally {
|
|
unregister();
|
|
}
|
|
|
|
expect(readAllowFromStore).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "zalo",
|
|
accountId: "work",
|
|
}),
|
|
);
|
|
expect(upsertPairingRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "zalo",
|
|
id: "123",
|
|
accountId: "work",
|
|
}),
|
|
);
|
|
});
|
|
});
|