mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(discord): bound REST response body to prevent OOM flood (#95412)
The Discord REST main response path read the body with an unbounded
await response.text() before JSON-parsing it. A controlled or hijacked
endpoint could stream an arbitrarily large body and exhaust memory (OOM).
Wrap the read in the canonical readResponseWithLimit helper with an 8 MiB
cap (well above any legitimate Discord JSON payload) plus an idle timeout
tied to the request timeout, so the stream is cancelled at the cap or on
stall instead of buffering unbounded. Normal payloads still parse fully.
This mirrors PR #95108 which bounded the analogous Anthropic Messages
error-response read with the same helper.
(cherry picked from commit 2d2a50c00d)
This commit is contained in:
@@ -692,6 +692,78 @@ describe("RequestClient", () => {
|
||||
expect(metrics.invalidRequestCountByStatus).toEqual({ 403: 1 });
|
||||
});
|
||||
|
||||
it("bounds oversized REST response bodies instead of buffering them unbounded", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let pullCount = 0;
|
||||
let cancelCount = 0;
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
// Flood far past the cap so an unbounded reader would OOM.
|
||||
controller.enqueue(encoder.encode("x".repeat(4 * 1024 * 1024)));
|
||||
},
|
||||
cancel() {
|
||||
cancelCount += 1;
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });
|
||||
|
||||
await expect(client.get("/channels/c1/messages")).rejects.toThrow(
|
||||
/Discord REST response body exceeds 8388608 bytes/,
|
||||
);
|
||||
// The reader was cancelled at the cap rather than draining the whole flood:
|
||||
// only a handful of 4 MiB chunks are pulled before the cap is hit.
|
||||
expect(cancelCount).toBe(1);
|
||||
expect(pullCount).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("aborts stalled REST response bodies after the idle timeout", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let cancelReason: unknown;
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Emit a partial chunk, then stall forever so the idle timeout
|
||||
// (request timeout) must fire and cancel the stream.
|
||||
controller.enqueue(encoder.encode("partial payload"));
|
||||
},
|
||||
cancel(reason) {
|
||||
cancelReason = reason;
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const client = new RequestClient("test-token", {
|
||||
fetch: fetchSpy,
|
||||
queueRequests: false,
|
||||
timeout: 50,
|
||||
});
|
||||
|
||||
await expect(client.get("/channels/c1/messages")).rejects.toThrow(
|
||||
"Discord REST response stalled: no data received for 50ms",
|
||||
);
|
||||
expect(cancelReason).toBeInstanceOf(Error);
|
||||
expect((cancelReason as Error).message).toBe(
|
||||
"Discord REST response stalled: no data received for 50ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("still parses normal-sized REST response payloads under the cap", async () => {
|
||||
const fetchSpy = vi.fn(async () => createJsonResponse({ id: "channel", name: "general" }));
|
||||
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });
|
||||
|
||||
await expect(client.get("/channels/c1")).resolves.toEqual({ id: "channel", name: "general" });
|
||||
});
|
||||
|
||||
it("serializes message multipart uploads with payload_json", () => {
|
||||
const headers = new Headers();
|
||||
const body = serializeRequestBody(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseFiniteNumber,
|
||||
resolveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { serializeRequestBody } from "./rest-body.js";
|
||||
import {
|
||||
DiscordError,
|
||||
@@ -89,6 +90,24 @@ const defaultLaneOptions: Record<RestRequestPriority, { staleAfterMs?: number; w
|
||||
background: { staleAfterMs: 20_000, weight: 1 },
|
||||
};
|
||||
|
||||
// Cap the REST response body well above any legitimate Discord JSON payload
|
||||
// (bulk message/member fetches stay in the low hundreds of KB) so a controlled
|
||||
// or hijacked endpoint cannot flood the body into an unbounded buffer (OOM).
|
||||
const DISCORD_REST_RESPONSE_BODY_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
async function readResponseBodyText(response: Response, idleTimeoutMs: number): Promise<string> {
|
||||
const buffer = await readResponseWithLimit(response, DISCORD_REST_RESPONSE_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: idleTimeoutMs,
|
||||
onOverflow: ({ size }) =>
|
||||
new Error(
|
||||
`Discord REST response body exceeds ${DISCORD_REST_RESPONSE_BODY_MAX_BYTES} bytes (received ${size})`,
|
||||
),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`Discord REST response stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
});
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
function coerceResponseBody(raw: string): unknown {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
@@ -249,7 +268,7 @@ export class RequestClient {
|
||||
body: await normalizeFetchBody(body, headers),
|
||||
signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const text = await readResponseBodyText(response, this.options.timeout ?? 15_000);
|
||||
const parsed = coerceResponseBody(text);
|
||||
this.scheduler.recordResponse(routeKey, path, response, parsed);
|
||||
if (response.status === 204) {
|
||||
|
||||
Reference in New Issue
Block a user