diff --git a/extensions/admin-http-rpc/src/handler.test.ts b/extensions/admin-http-rpc/src/handler.test.ts index 272e0e39bd83..546c32be56f5 100644 --- a/extensions/admin-http-rpc/src/handler.test.ts +++ b/extensions/admin-http-rpc/src/handler.test.ts @@ -1,4 +1,6 @@ // Admin Http Rpc tests cover handler plugin behavior. +import { createServer, type Server } from "node:http"; +import { connect, type Socket } from "node:net"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { handleAdminHttpRpcRequest } from "./handler.js"; @@ -30,6 +32,22 @@ function createRequest(body: unknown, method = "POST") { return req as import("node:http").IncomingMessage; } +function createHangingRequest() { + const req = new Readable({ + read() { + // Keep the body open so the handler's request-body timeout owns settlement. + }, + }); + Object.assign(req, { + method: "POST", + url: "/api/v1/admin/rpc", + headers: { + "content-type": "application/json", + }, + }); + return req as import("node:http").IncomingMessage; +} + function createResponse() { const captured: CapturedResponse = { statusCode: 200, @@ -54,8 +72,12 @@ function createResponse() { } async function invoke(body: unknown, method = "POST") { + return invokeRequest(createRequest(body, method)); +} + +async function invokeRequest(req: import("node:http").IncomingMessage) { const { res, captured } = createResponse(); - const handled = await handleAdminHttpRpcRequest(createRequest(body, method), res); + const handled = await handleAdminHttpRpcRequest(req, res); return { handled, captured, @@ -63,6 +85,35 @@ async function invoke(body: unknown, method = "POST") { }; } +async function listen(server: Server): Promise { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected TCP server address"); + } + return address.port; +} + +async function readSocketResponse(socket: Socket): Promise { + const chunks: Buffer[] = []; + return await new Promise((resolve, reject) => { + let done = false; + const finish = () => { + if (done) { + return; + } + done = true; + resolve(Buffer.concat(chunks).toString("utf8")); + }; + socket.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on("end", finish); + socket.on("close", finish); + socket.on("error", reject); + }); +} + describe("admin-http-rpc plugin handler", () => { beforeEach(() => { dispatchGatewayMethod.mockReset(); @@ -206,4 +257,81 @@ describe("admin-http-rpc plugin handler", () => { expect(result.captured.headers.allow).toBe("POST"); expect(dispatchGatewayMethod).not.toHaveBeenCalled(); }); + + it("times out incomplete request bodies before dispatch", async () => { + vi.useFakeTimers(); + try { + const resultPromise = invokeRequest(createHangingRequest()); + await vi.advanceTimersByTimeAsync(30_000); + const result = await resultPromise; + + expect(result.handled).toBe(true); + expect(result.captured.statusCode).toBe(408); + expect(result.json).toEqual({ + ok: false, + error: { + type: "invalid_request", + message: "Request body timeout", + }, + }); + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("delivers a real HTTP 408 response before closing timed-out partial bodies", async () => { + vi.useFakeTimers(); + let markRequestStarted: (() => void) | undefined; + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve; + }); + const server = createServer((req, res) => { + void handleAdminHttpRpcRequest(req, res); + markRequestStarted?.(); + }); + let socket: Socket | undefined; + try { + const port = await listen(server); + socket = connect({ host: "127.0.0.1", port }); + await new Promise((resolve) => { + socket?.once("connect", resolve); + }); + + socket.write( + [ + "POST /api/v1/admin/rpc HTTP/1.1", + "Host: 127.0.0.1", + "Content-Type: application/json", + "Content-Length: 64", + "Connection: close", + "", + "{", + ].join("\r\n"), + ); + + await requestStarted; + const responsePromise = readSocketResponse(socket); + await vi.advanceTimersByTimeAsync(30_000); + const response = await responsePromise; + const [, rawBody = ""] = response.split("\r\n\r\n", 2); + + expect(response).toContain("HTTP/1.1 408"); + expect(response).toContain("Connection: close"); + expect(JSON.parse(rawBody) as unknown).toEqual({ + ok: false, + error: { + type: "invalid_request", + message: "Request body timeout", + }, + }); + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + socket?.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); }); diff --git a/extensions/admin-http-rpc/src/handler.ts b/extensions/admin-http-rpc/src/handler.ts index 672f1a70348c..b742d1c146e5 100644 --- a/extensions/admin-http-rpc/src/handler.ts +++ b/extensions/admin-http-rpc/src/handler.ts @@ -6,10 +6,12 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + requestBodyErrorToText, + WEBHOOK_BODY_READ_DEFAULTS, +} from "openclaw/plugin-sdk/webhook-request-guards"; import { isAdminHttpRpcAllowedMethod, listAdminHttpRpcAllowedMethods } from "./methods.js"; -const DEFAULT_RPC_BODY_BYTES = 1024 * 1024; - const ErrorCodes = { AGENT_TIMEOUT: "AGENT_TIMEOUT", APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND", @@ -43,6 +45,22 @@ type ParsedRequest = { params?: unknown; }; +type RequestBodyLimitFailureCode = + | "PAYLOAD_TOO_LARGE" + | "REQUEST_BODY_TIMEOUT" + | "CONNECTION_CLOSED"; +type ReadJsonBodyResult = + | { ok: true; value: unknown } + | { + ok: false; + status: number; + message: string; + closeAfterResponse?: boolean; + }; +type ReadRawBodyResult = + | { ok: true; raw: string } + | { ok: false; code: RequestBodyLimitFailureCode; closeAfterResponse?: boolean }; + function createError(code: string, message: string): RpcError { return { code, message }; } @@ -82,33 +100,145 @@ function sendError(res: ServerResponse, status: number, error: { type: string; m async function readJsonBody( req: IncomingMessage, maxBytes: number, -): Promise<{ ok: true; value: unknown } | { ok: false; status: number; message: string }> { - const chunks: Buffer[] = []; - let totalBytes = 0; - try { - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalBytes += buffer.byteLength; - if (totalBytes > maxBytes) { - return { ok: false, status: 413, message: "Payload too large" }; - } - chunks.push(buffer); - } - } catch { - return { ok: false, status: 400, message: "failed to read request body" }; + timeoutMs: number, +): Promise { + const body = await readRawBodyWithLimit(req, maxBytes, timeoutMs); + if (!body.ok) { + return { + ok: false, + status: statusForBodyErrorCode(body.code), + message: requestBodyErrorToText(body.code), + closeAfterResponse: body.closeAfterResponse, + }; } - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw.trim()) { + const raw = body.raw.trim(); + if (!raw) { return { ok: false, status: 400, message: "request body must be JSON" }; } try { - return { ok: true, value: JSON.parse(raw) }; + return { ok: true, value: JSON.parse(raw) as unknown }; } catch { return { ok: false, status: 400, message: "request body must be valid JSON" }; } } +function statusForBodyErrorCode(code: RequestBodyLimitFailureCode): number { + switch (code) { + case "PAYLOAD_TOO_LARGE": + return 413; + case "REQUEST_BODY_TIMEOUT": + return 408; + case "CONNECTION_CLOSED": + return 400; + } + return 400; +} + +async function readRawBodyWithLimit( + req: IncomingMessage, + maxBytes: number, + timeoutMs: number, +): Promise { + const declaredLength = readDeclaredContentLength(req); + if (declaredLength !== null && declaredLength > maxBytes) { + req.pause(); + return { ok: false, code: "PAYLOAD_TOO_LARGE", closeAfterResponse: true }; + } + + return await new Promise((resolve) => { + let done = false; + let ended = false; + let totalBytes = 0; + const chunks: Buffer[] = []; + + const cleanup = () => { + req.removeListener("data", onData); + req.removeListener("end", onEnd); + req.removeListener("error", onError); + req.removeListener("close", onClose); + clearTimeout(timer); + }; + + const finish = (result: ReadRawBodyResult) => { + if (done) { + return; + } + done = true; + cleanup(); + resolve(result); + }; + + const failAfterResponse = (code: RequestBodyLimitFailureCode) => { + req.pause(); + finish({ ok: false, code, closeAfterResponse: true }); + }; + + const timer = setTimeout(() => { + failAfterResponse("REQUEST_BODY_TIMEOUT"); + }, timeoutMs); + + const onData = (chunk: Buffer | string) => { + if (done) { + return; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > maxBytes) { + failAfterResponse("PAYLOAD_TOO_LARGE"); + return; + } + chunks.push(buffer); + }; + + const onEnd = () => { + ended = true; + finish({ ok: true, raw: Buffer.concat(chunks).toString("utf8") }); + }; + + const onError = () => { + finish({ ok: false, code: "CONNECTION_CLOSED" }); + }; + + const onClose = () => { + if (done || ended) { + return; + } + finish({ ok: false, code: "CONNECTION_CLOSED" }); + }; + + req.on("data", onData); + req.on("end", onEnd); + req.on("error", onError); + req.on("close", onClose); + }); +} + +function readDeclaredContentLength(req: IncomingMessage): number | null { + const header = req.headers["content-length"]; + const raw = Array.isArray(header) ? header[0] : header; + if (typeof raw !== "string" || !/^\d+$/.test(raw)) { + return null; + } + const value = Number(raw); + return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER; +} + +function closeRequestAfterResponse(req: IncomingMessage, res: ServerResponse): void { + const once = (res as { once?: ServerResponse["once"] }).once; + if (typeof once !== "function") { + return; + } + res.setHeader("Connection", "close"); + once.call(res, "finish", () => { + // Timeout/size failures must flush JSON first; destroying before finish drops + // the HTTP response on real partial-body sockets. + if (!req.destroyed) { + req.destroy(); + } + }); +} + function readRpcRequestBody(body: unknown): | { ok: true; request: ParsedRequest } | { @@ -202,8 +332,15 @@ export async function handleAdminHttpRpcRequest( return true; } - const body = await readJsonBody(req, DEFAULT_RPC_BODY_BYTES); + const body = await readJsonBody( + req, + WEBHOOK_BODY_READ_DEFAULTS.postAuth.maxBytes, + WEBHOOK_BODY_READ_DEFAULTS.postAuth.timeoutMs, + ); if (!body.ok) { + if (body.closeAfterResponse) { + closeRequestAfterResponse(req, res); + } sendError(res, body.status, { type: "invalid_request", message: body.message,