Skip to content

Commit 172cffd

Browse files
committed
fix(discord): bound REST response body to prevent OOM flood
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.
1 parent 20d1dc8 commit 172cffd

2 files changed

Lines changed: 92 additions & 1 deletion

File tree

extensions/discord/src/internal/rest.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,78 @@ describe("RequestClient", () => {
692692
expect(metrics.invalidRequestCountByStatus).toEqual({ 403: 1 });
693693
});
694694

695+
it("bounds oversized REST response bodies instead of buffering them unbounded", async () => {
696+
const encoder = new TextEncoder();
697+
let pullCount = 0;
698+
let cancelCount = 0;
699+
const fetchSpy = vi.fn(
700+
async () =>
701+
new Response(
702+
new ReadableStream<Uint8Array>({
703+
pull(controller) {
704+
pullCount += 1;
705+
// Flood far past the cap so an unbounded reader would OOM.
706+
controller.enqueue(encoder.encode("x".repeat(4 * 1024 * 1024)));
707+
},
708+
cancel() {
709+
cancelCount += 1;
710+
},
711+
}),
712+
{ status: 200 },
713+
),
714+
);
715+
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });
716+
717+
await expect(client.get("/channels/c1/messages")).rejects.toThrow(
718+
/Discord REST response body exceeds 8388608 bytes/,
719+
);
720+
// The reader was cancelled at the cap rather than draining the whole flood:
721+
// only a handful of 4 MiB chunks are pulled before the cap is hit.
722+
expect(cancelCount).toBe(1);
723+
expect(pullCount).toBeLessThanOrEqual(4);
724+
});
725+
726+
it("aborts stalled REST response bodies after the idle timeout", async () => {
727+
const encoder = new TextEncoder();
728+
let cancelReason: unknown;
729+
const fetchSpy = vi.fn(
730+
async () =>
731+
new Response(
732+
new ReadableStream<Uint8Array>({
733+
start(controller) {
734+
// Emit a partial chunk, then stall forever so the idle timeout
735+
// (request timeout) must fire and cancel the stream.
736+
controller.enqueue(encoder.encode("partial payload"));
737+
},
738+
cancel(reason) {
739+
cancelReason = reason;
740+
},
741+
}),
742+
{ status: 200 },
743+
),
744+
);
745+
const client = new RequestClient("test-token", {
746+
fetch: fetchSpy,
747+
queueRequests: false,
748+
timeout: 50,
749+
});
750+
751+
await expect(client.get("/channels/c1/messages")).rejects.toThrow(
752+
"Discord REST response stalled: no data received for 50ms",
753+
);
754+
expect(cancelReason).toBeInstanceOf(Error);
755+
expect((cancelReason as Error).message).toBe(
756+
"Discord REST response stalled: no data received for 50ms",
757+
);
758+
});
759+
760+
it("still parses normal-sized REST response payloads under the cap", async () => {
761+
const fetchSpy = vi.fn(async () => createJsonResponse({ id: "channel", name: "general" }));
762+
const client = new RequestClient("test-token", { fetch: fetchSpy, queueRequests: false });
763+
764+
await expect(client.get("/channels/c1")).resolves.toEqual({ id: "channel", name: "general" });
765+
});
766+
695767
it("serializes message multipart uploads with payload_json", () => {
696768
const headers = new Headers();
697769
const body = serializeRequestBody(

extensions/discord/src/internal/rest.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseFiniteNumber,
77
resolveTimerTimeoutMs,
88
} from "openclaw/plugin-sdk/number-runtime";
9+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
910
import { serializeRequestBody } from "./rest-body.js";
1011
import {
1112
DiscordError,
@@ -89,6 +90,24 @@ const defaultLaneOptions: Record<RestRequestPriority, { staleAfterMs?: number; w
8990
background: { staleAfterMs: 20_000, weight: 1 },
9091
};
9192

93+
// Cap the REST response body well above any legitimate Discord JSON payload
94+
// (bulk message/member fetches stay in the low hundreds of KB) so a controlled
95+
// or hijacked endpoint cannot flood the body into an unbounded buffer (OOM).
96+
const DISCORD_REST_RESPONSE_BODY_MAX_BYTES = 8 * 1024 * 1024;
97+
98+
async function readResponseBodyText(response: Response, idleTimeoutMs: number): Promise<string> {
99+
const buffer = await readResponseWithLimit(response, DISCORD_REST_RESPONSE_BODY_MAX_BYTES, {
100+
chunkTimeoutMs: idleTimeoutMs,
101+
onOverflow: ({ size }) =>
102+
new Error(
103+
`Discord REST response body exceeds ${DISCORD_REST_RESPONSE_BODY_MAX_BYTES} bytes (received ${size})`,
104+
),
105+
onIdleTimeout: ({ chunkTimeoutMs }) =>
106+
new Error(`Discord REST response stalled: no data received for ${chunkTimeoutMs}ms`),
107+
});
108+
return buffer.toString("utf8");
109+
}
110+
92111
function coerceResponseBody(raw: string): unknown {
93112
if (!raw) {
94113
return undefined;
@@ -249,7 +268,7 @@ export class RequestClient {
249268
body: await normalizeFetchBody(body, headers),
250269
signal,
251270
});
252-
const text = await response.text();
271+
const text = await readResponseBodyText(response, this.options.timeout ?? 15_000);
253272
const parsed = coerceResponseBody(text);
254273
this.scheduler.recordResponse(routeKey, path, response, parsed);
255274
if (response.status === 204) {

0 commit comments

Comments
 (0)