mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user