From fc77c2b04b4de0eab86de6f3fee6ef1b6b245806 Mon Sep 17 00:00:00 2001 From: wings1029 Date: Tue, 7 Jul 2026 06:34:03 +0800 Subject: [PATCH] fix(discord): bound gateway metadata response body reads to prevent OOM (#98682) * fix(discord): bound gateway metadata response body reads to prevent OOM The materializeGuardedResponse helper in the Discord gateway metadata path buffered the full upstream Response body via response.arrayBuffer() without any size cap. A malicious or malfunctioning /gateway/bot endpoint that returns an oversized payload could exhaust gateway memory. Replace arrayBuffer() with readResponseWithLimit(4 MiB), consistent with DISCORD_API_RESPONSE_BODY_LIMIT_BYTES in api.ts. Overflow throws an Error with the byte counts. Co-Authored-By: Claude * fix(discord): wrap readResponseWithLimit result in Uint8Array for type compat readResponseWithLimit returns Buffer which is not assignable to BodyInit in the undici Response constructor type. Wrap in new Uint8Array() to satisfy the boundary dts check. Also remove testExports export and mock fetchWithSsrFGuard in tests via vi.hoisted + vi.mock to avoid leaking internal test-only exports. Co-Authored-By: Claude * test(discord): tighten gateway metadata proof --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- .../src/monitor/gateway-metadata.test.ts | 118 +++++++++++++++++- .../discord/src/monitor/gateway-metadata.ts | 11 +- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor/gateway-metadata.test.ts b/extensions/discord/src/monitor/gateway-metadata.test.ts index 155771b81af..962acadb0d0 100644 --- a/extensions/discord/src/monitor/gateway-metadata.test.ts +++ b/extensions/discord/src/monitor/gateway-metadata.test.ts @@ -1,11 +1,53 @@ // Discord tests cover gateway metadata plugin behavior. -import { describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchDiscordGatewayInfo, + fetchDiscordGatewayMetadataGuarded, resolveDiscordGatewayInfoTimeoutMs, resolveGatewayInfoWithFallback, } from "./gateway-metadata.js"; +const DISCORD_GATEWAY_METADATA_MAX_BYTES = 4 * 1024 * 1024; + +const { mockFetchWithSsrFGuard } = vi.hoisted(() => ({ + mockFetchWithSsrFGuard: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: mockFetchWithSsrFGuard, +})); + +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 stubGuardedFetch(response: Response) { + const release = vi.fn<() => Promise>().mockResolvedValue(undefined); + mockFetchWithSsrFGuard.mockResolvedValue({ + response, + release, + }); + return release; +} + describe("Discord gateway metadata", () => { it("resolves gateway info timeouts from strict integer config and env values", () => { expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000); @@ -53,3 +95,77 @@ describe("Discord gateway metadata", () => { ); }); }); + +describe("fetchDiscordGatewayMetadataGuarded bounded reads", () => { + beforeEach(() => { + vi.useRealTimers(); + mockFetchWithSsrFGuard.mockReset(); + }); + + it("returns under-cap response bodies and releases the guarded fetch", async () => { + const payload = JSON.stringify({ + url: "wss://gateway.discord.gg/", + shards: 1, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 3600000, + max_concurrency: 1, + }, + }); + + const release = stubGuardedFetch( + new Response(payload, { headers: { "content-type": "application/json" } }), + ); + + const response = await fetchDiscordGatewayMetadataGuarded( + "https://discord.com/api/v10/gateway/bot", + ); + + await expect(response.json()).resolves.toEqual(JSON.parse(payload)); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects oversized response body from a real loopback HTTP server", async () => { + const oversizedPayloadBytes = DISCORD_GATEWAY_METADATA_MAX_BYTES + 256 * 1024; + let streamedBytes = 0; + + const server = createServer((_request, res) => { + const chunk = Buffer.alloc(64 * 1024, 0x78); + res.writeHead(200, { "content-type": "application/json" }); + + 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 { + const rawResponse = await fetch(`http://127.0.0.1:${port}`); + const release = stubGuardedFetch(rawResponse); + + await expect( + fetchDiscordGatewayMetadataGuarded("https://discord.com/api/v10/gateway/bot"), + ).rejects.toThrow( + new RegExp( + `Discord gateway metadata response body too large: \\d+ bytes \\(limit: ${DISCORD_GATEWAY_METADATA_MAX_BYTES} bytes\\)`, + ), + ); + expect(release).toHaveBeenCalledOnce(); + } finally { + await closeServer(server); + } + }); +}); diff --git a/extensions/discord/src/monitor/gateway-metadata.ts b/extensions/discord/src/monitor/gateway-metadata.ts index 64decd7fde4..372f8caafd3 100644 --- a/extensions/discord/src/monitor/gateway-metadata.ts +++ b/extensions/discord/src/monitor/gateway-metadata.ts @@ -3,6 +3,7 @@ import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { captureHttpExchange } from "openclaw/plugin-sdk/proxy-capture"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { Type } from "typebox"; @@ -16,6 +17,7 @@ const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; const DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 30_000; const MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 120_000; const DISCORD_GATEWAY_INFO_TIMEOUT_ENV = "OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS"; +const DISCORD_GATEWAY_METADATA_MAX_BYTES = 4 * 1024 * 1024; const DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS = 60_000; type DiscordGatewayMetadataResponse = Pick; @@ -57,7 +59,14 @@ function resolveFetchInputUrl(input: RequestInfo | URL): string { } async function materializeGuardedResponse(response: Response): Promise { - const body = await response.arrayBuffer(); + const body = new Uint8Array( + await readResponseWithLimit(response, DISCORD_GATEWAY_METADATA_MAX_BYTES, { + onOverflow: ({ size, maxBytes }) => + new Error( + `Discord gateway metadata response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, + ), + }), + ); return new Response(body, { status: response.status, statusText: response.statusText,