fix(discord): bound requestDiscord happy-path response reads to prevent OOM (#97693)

* 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
This commit is contained in:
Alix-007
2026-07-01 08:46:51 +08:00
committed by GitHub
parent aadd57c31c
commit fca15641db
2 changed files with 150 additions and 2 deletions
+141 -1
View File
@@ -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<number> {
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<void> {
await new Promise<void>((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<typeof payload>("/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);
}
});
});
+9 -1
View File
@@ -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<T>(
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;
}