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;
}