From fca15641dba57129cefafbc7f15a666d90243430 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Wed, 1 Jul 2026 08:46:51 +0800 Subject: [PATCH] fix(discord): bound requestDiscord happy-path response reads to prevent OOM (#97693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(discord): bound happy-path API response reads to prevent OOM Replace the unbounded res.text() call in requestDiscord's success path with readResponseTextLimited capped at 4 MiB. Discord channel message lists and attachment payloads can accumulate to large sizes; without a cap the process can exhaust available memory. The error path already used readResponseTextLimited with DISCORD_API_ERROR_BODY_LIMIT_BYTES — this applies the same guard to the happy path using a separate DISCORD_API_RESPONSE_BODY_LIMIT_BYTES constant sized appropriately for valid API payloads. * test(discord): upgrade to real HTTP server proof for bound requestDiscord * fix(discord): remove unnecessary type assertion in bound test --- extensions/discord/src/api.test.ts | 142 ++++++++++++++++++++++++++++- extensions/discord/src/api.ts | 10 +- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 5187283f6108..131200925b7c 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,10 +1,13 @@ // Discord tests cover api plugin behavior. +import { createServer, type Server } from "node:http"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; +const DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES = 4 * 1024 * 1024; + function cancelTrackedResponse( text: string, init: ResponseInit, @@ -27,11 +30,56 @@ function cancelTrackedResponse( }; } +async function listenLoopbackServer(server: Server): Promise { + return await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("expected loopback TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function stubDiscordFetchToLoopback( + baseUrl: string, + onResponse?: (response: Response) => void, +): void { + const realFetch = globalThis.fetch.bind(globalThis); + vi.stubGlobal( + "fetch", + withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { + const originalUrl = new URL(input instanceof Request ? input.url : String(input)); + expect(originalUrl.origin).toBe("https://discord.com"); + expect(originalUrl.pathname).toMatch(/^\/api\/v10\//); + const loopbackUrl = new URL(`${originalUrl.pathname}${originalUrl.search}`, baseUrl); + const response = await realFetch(loopbackUrl, init); + onResponse?.(response); + return response; + }), + ); +} + describe("fetchDiscord", () => { beforeEach(() => { vi.useRealTimers(); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it("formats rate limit payloads without raw JSON", async () => { const fetcher = withFetchPreconnect(async () => jsonResponse( @@ -272,4 +320,96 @@ describe("fetchDiscord", () => { expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); expect(request?.signal).toBe(timeoutController.signal); }); + + it("returns under-cap requestDiscord responses from a real loopback HTTP server", async () => { + const payload = { id: "channel-42", name: "loopback", type: 0 }; + let contentLength: string | null | undefined; + let requestUrl: string | undefined; + let authorization: string | undefined; + const server = createServer((req, res) => { + requestUrl = req.url; + authorization = req.headers.authorization; + const body = JSON.stringify(payload); + res.writeHead(200, { "content-type": "application/json" }); + res.write(body.slice(0, 12)); + res.end(body.slice(12)); + }); + const port = await listenLoopbackServer(server); + + try { + stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => { + contentLength = response.headers.get("content-length"); + }); + + const result = await requestDiscord("/channels/channel-42", "test-token", { + retry: { attempts: 1 }, + }); + + expect(result).toEqual(payload); + expect(requestUrl).toBe("/api/v10/channels/channel-42"); + expect(authorization).toBe("Bot test-token"); + expect(contentLength).toBeNull(); + console.log( + `[discord requestDiscord loopback proof] normal path: returned=${JSON.stringify(result)} content_length=${contentLength ?? "none"}`, + ); + } finally { + await closeServer(server); + } + }); + + it("rejects oversized valid JSON requestDiscord responses from a real loopback HTTP server", async () => { + const oversizedPayloadBytes = DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES + 256 * 1024; + let contentLength: string | null | undefined; + let requestUrl: string | undefined; + let streamedBytes = 0; + const server = createServer((req, res) => { + requestUrl = req.url; + const chunk = Buffer.alloc(64 * 1024, 0x78); + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"id":"'); + + const writeMore = () => { + while (streamedBytes < oversizedPayloadBytes) { + if (res.destroyed) { + return; + } + streamedBytes += chunk.byteLength; + if (!res.write(chunk)) { + res.once("drain", writeMore); + return; + } + } + res.end('"}'); + }; + + writeMore(); + }); + const port = await listenLoopbackServer(server); + + try { + stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => { + contentLength = response.headers.get("content-length"); + }); + + let error: unknown; + try { + await requestDiscord("/channels/123/messages", "test-token", { + retry: { attempts: 1 }, + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("Discord API /channels/123/messages response body too large"); + expect(String(error)).toContain(`limit: ${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} bytes`); + expect(requestUrl).toBe("/api/v10/channels/123/messages"); + expect(contentLength).toBeNull(); + console.log( + `[discord requestDiscord loopback proof] oversized path: cap=${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} streamed>=${streamedBytes} content_length=${contentLength ?? "none"} rejected=${String(error)}`, + ); + } finally { + await closeServer(server); + } + }); }); diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts index 7979d4ebf64d..a1981629a54e 100644 --- a/extensions/discord/src/api.ts +++ b/extensions/discord/src/api.ts @@ -2,6 +2,7 @@ import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { resolveRetryConfig, retryAsync, @@ -19,6 +20,7 @@ const DISCORD_API_RETRY_DEFAULTS = { }; const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60; const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +const DISCORD_API_RESPONSE_BODY_LIMIT_BYTES = 4 * 1024 * 1024; type DiscordApiErrorPayload = { message?: string; @@ -191,7 +193,13 @@ export async function requestDiscord( retryAfter, ); } - const text = await res.text().catch(() => ""); + const responseBody = await readResponseWithLimit(res, DISCORD_API_RESPONSE_BODY_LIMIT_BYTES, { + onOverflow: ({ size, maxBytes }) => + new Error( + `Discord API ${path} response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, + ), + }); + const text = new TextDecoder().decode(responseBody); if (!text.trim()) { return undefined as T; }