From 6df36a8b350120bde8094cc959bbb42513343915 Mon Sep 17 00:00:00 2001 From: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:40:10 -0700 Subject: [PATCH] fix(synology-chat): bound webhook body read time --- .../synology-chat/src/webhook-handler.test.ts | 43 +++++++++++++ .../synology-chat/src/webhook-handler.ts | 61 +++++++++++-------- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 0c4e8c17e..69ce8e6f7 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage } from "node:http"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; @@ -30,6 +32,24 @@ function makeAccount( }; } +function makeStalledReq(method: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { + destroyed: boolean; + destroy: () => void; + }; + req.method = method; + req.headers = {}; + req.socket = { remoteAddress: "127.0.0.1" } as any; + req.destroyed = false; + req.destroy = () => { + if (req.destroyed) { + return; + } + req.destroyed = true; + }; + return req; +} + const validBody = makeFormBody({ token: "valid-token", user_id: "123", @@ -95,6 +115,29 @@ describe("createWebhookHandler", () => { expect(res._status).toBe(400); }); + it("returns 408 when request body times out", async () => { + vi.useFakeTimers(); + try { + const handler = createWebhookHandler({ + account: makeAccount(), + deliver: vi.fn(), + log, + }); + + const req = makeStalledReq("POST"); + const res = makeRes(); + const run = handler(req, res); + + await vi.advanceTimersByTimeAsync(30_000); + await run; + + expect(res._status).toBe(408); + expect(res._body).toContain("timeout"); + } finally { + vi.useRealTimers(); + } + }); + it("returns 401 for invalid token", async () => { const handler = createWebhookHandler({ account: makeAccount(), diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 08666a352..fe02c06c6 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -5,6 +5,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; +import { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk"; import { sendMessage } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; @@ -34,24 +39,34 @@ export function getSynologyWebhookRateLimiterCountForTest(): number { } /** Read the full request body as a string. */ -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let size = 0; - const maxSize = 1_048_576; // 1MB - - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > maxSize) { - req.destroy(); - reject(new Error("Request body too large")); - return; - } - chunks.push(chunk); +async function readBody(req: IncomingMessage): Promise< + | { ok: true; body: string } + | { + ok: false; + statusCode: number; + error: string; + } +> { + try { + const body = await readRequestBodyWithLimit(req, { + maxBytes: 1_048_576, + timeoutMs: 30_000, }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); - req.on("error", reject); - }); + return { ok: true, body }; + } catch (err) { + if (isRequestBodyLimitError(err)) { + return { + ok: false, + statusCode: err.statusCode, + error: requestBodyErrorToText(err.code), + }; + } + return { + ok: false, + statusCode: 400, + error: "Invalid request body", + }; + } } /** Parse form-urlencoded body into SynologyWebhookPayload. */ @@ -126,17 +141,15 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { } // Parse body - let body: string; - try { - body = await readBody(req); - } catch (err) { - log?.error("Failed to read request body", err); - respond(res, 400, { error: "Invalid request body" }); + const body = await readBody(req); + if (!body.ok) { + log?.error("Failed to read request body", body.error); + respond(res, body.statusCode, { error: body.error }); return; } // Parse payload - const payload = parsePayload(body); + const payload = parsePayload(body.body); if (!payload) { respond(res, 400, { error: "Missing required fields (token, user_id, text)" }); return;