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 { describe, it, expect, vi, beforeEach } from "vitest";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
import type { ResolvedSynologyChatAccount } from "./types.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({ const validBody = makeFormBody({
token: "valid-token", token: "valid-token",
user_id: "123", user_id: "123",
@@ -95,6 +115,29 @@ describe("createWebhookHandler", () => {
expect(res._status).toBe(400); 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 () => { it("returns 401 for invalid token", async () => {
const handler = createWebhookHandler({ const handler = createWebhookHandler({
account: makeAccount(), account: makeAccount(),

View File

@@ -5,6 +5,11 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import * as querystring from "node:querystring"; import * as querystring from "node:querystring";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk";
import { sendMessage } from "./client.js"; import { sendMessage } from "./client.js";
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
@@ -34,24 +39,34 @@ export function getSynologyWebhookRateLimiterCountForTest(): number {
} }
/** Read the full request body as a string. */ /** Read the full request body as a string. */
function readBody(req: IncomingMessage): Promise<string> { async function readBody(req: IncomingMessage): Promise<
return new Promise((resolve, reject) => { | { ok: true; body: string }
const chunks: Buffer[] = []; | {
let size = 0; ok: false;
const maxSize = 1_048_576; // 1MB statusCode: number;
error: string;
req.on("data", (chunk: Buffer) => { }
size += chunk.length; > {
if (size > maxSize) { try {
req.destroy(); const body = await readRequestBodyWithLimit(req, {
reject(new Error("Request body too large")); maxBytes: 1_048_576,
return; timeoutMs: 30_000,
}
chunks.push(chunk);
}); });
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); return { ok: true, body };
req.on("error", reject); } 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. */ /** Parse form-urlencoded body into SynologyWebhookPayload. */
@@ -126,17 +141,15 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
} }
// Parse body // Parse body
let body: string; const body = await readBody(req);
try { if (!body.ok) {
body = await readBody(req); log?.error("Failed to read request body", body.error);
} catch (err) { respond(res, body.statusCode, { error: body.error });
log?.error("Failed to read request body", err);
respond(res, 400, { error: "Invalid request body" });
return; return;
} }
// Parse payload // Parse payload
const payload = parsePayload(body); const payload = parsePayload(body.body);
if (!payload) { if (!payload) {
respond(res, 400, { error: "Missing required fields (token, user_id, text)" }); respond(res, 400, { error: "Missing required fields (token, user_id, text)" });
return; return;