fix(synology-chat): bound webhook body read time

This commit is contained in:
bmendonca3
2026-02-24 14:40:10 -07:00
committed by Peter Steinberger
parent fbd1210ec2
commit 6df36a8b35
2 changed files with 80 additions and 24 deletions

View File

@@ -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(),

View File

@@ -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<string> {
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;