diff --git a/extensions/feishu/src/monitor.state.ts b/extensions/feishu/src/monitor.state.ts index 55b8d0dea5a..3100c1da839 100644 --- a/extensions/feishu/src/monitor.state.ts +++ b/extensions/feishu/src/monitor.state.ts @@ -13,8 +13,8 @@ export const httpServers = new Map(); export const botOpenIds = new Map(); export const botNames = new Map(); -export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; -export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000; +export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 64 * 1024; +export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 5_000; type WebhookRateLimitDefaults = { windowMs: number; diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index 957d874cc3a..60b5517e66c 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -1,3 +1,4 @@ +import { createConnection } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createFeishuClientMockModule, @@ -35,6 +36,47 @@ import { stopFeishuMonitor, } from "./monitor.js"; +async function waitForSlowBodyTimeoutResponse( + url: string, + timeoutMs: number, +): Promise<{ body: string; elapsedMs: number }> { + return await new Promise<{ body: string; elapsedMs: number }>((resolve, reject) => { + const target = new URL(url); + const startedAt = Date.now(); + let response = ""; + const socket = createConnection( + { + host: target.hostname, + port: Number(target.port), + }, + () => { + socket.write(`POST ${target.pathname} HTTP/1.1\r\n`); + socket.write(`Host: ${target.hostname}\r\n`); + socket.write("Content-Type: application/json\r\n"); + socket.write("Content-Length: 65536\r\n"); + socket.write("\r\n"); + socket.write('{"type":"url_verification"'); + }, + ); + + socket.setEncoding("utf8"); + socket.on("error", () => {}); + socket.on("data", (chunk) => { + response += chunk; + if (response.includes("Request body timeout")) { + clearTimeout(failTimer); + socket.destroy(); + resolve({ body: response, elapsedMs: Date.now() - startedAt }); + } + }); + + const failTimer = setTimeout(() => { + socket.destroy(); + reject(new Error(`timeout response did not arrive within ${timeoutMs}ms`)); + }, timeoutMs); + }); +} + afterEach(() => { clearFeishuWebhookRateLimitStateForTest(); stopFeishuMonitor(); @@ -91,6 +133,48 @@ describe("Feishu webhook security hardening", () => { ); }); + it("rejects oversized unsigned webhook bodies with 413 before signature verification", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + await withRunningWebhookMonitor( + { + accountId: "payload-too-large", + path: "/hook-payload-too-large", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + monitorFeishuProvider, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ payload: "x".repeat(70 * 1024) }), + }); + + expect(response.status).toBe(413); + expect(await response.text()).toBe("Payload too large"); + }, + ); + }); + + it("drops slow-body webhook requests within the tightened pre-auth timeout", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + await withRunningWebhookMonitor( + { + accountId: "slow-body-timeout", + path: "/hook-slow-body-timeout", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + monitorFeishuProvider, + async (url) => { + const result = await waitForSlowBodyTimeoutResponse(url, 15_000); + expect(result.body).toContain("408 Request Timeout"); + expect(result.body).toContain("Request body timeout"); + expect(result.elapsedMs).toBeLessThan(12_000); + }, + ); + }); + it("rate limits webhook burst traffic with 429", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor(