fix(discord): bound gateway metadata response body reads to prevent OOM (#98682)
Some checks are pending
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / native-i18n (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / plan (push) Waiting to run
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Waiting to run
Native App Locale Refresh / Refresh native de (push) Waiting to run
Native App Locale Refresh / Refresh native es (push) Waiting to run
Native App Locale Refresh / Refresh native fa (push) Waiting to run
Native App Locale Refresh / Refresh native fr (push) Waiting to run
Native App Locale Refresh / Refresh native hi (push) Waiting to run
Native App Locale Refresh / Refresh native id (push) Waiting to run
Native App Locale Refresh / Refresh native it (push) Waiting to run
Native App Locale Refresh / Refresh native ja-JP (push) Waiting to run
Native App Locale Refresh / Refresh native ko (push) Waiting to run
Native App Locale Refresh / Refresh native nl (push) Waiting to run
Native App Locale Refresh / Refresh native pl (push) Waiting to run
Native App Locale Refresh / Refresh native pt-BR (push) Waiting to run
Native App Locale Refresh / Refresh native ru (push) Waiting to run
Native App Locale Refresh / Refresh native sv (push) Waiting to run
Native App Locale Refresh / Refresh native th (push) Waiting to run
Native App Locale Refresh / Refresh native tr (push) Waiting to run
Native App Locale Refresh / Refresh native uk (push) Waiting to run
Native App Locale Refresh / Refresh native vi (push) Waiting to run
Native App Locale Refresh / Refresh native zh-CN (push) Waiting to run
Native App Locale Refresh / Refresh native zh-TW (push) Waiting to run
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test(discord): tighten gateway metadata proof

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wings1029 2026-07-07 06:34:03 +08:00 committed by GitHub
parent d931839e0c
commit fc77c2b04b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 2 deletions

View file

@ -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<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 stubGuardedFetch(response: Response) {
const release = vi.fn<() => Promise<void>>().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);
}
});
});

View file

@ -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<Response, "ok" | "status" | "text">;
@ -57,7 +59,14 @@ function resolveFetchInputUrl(input: RequestInfo | URL): string {
}
async function materializeGuardedResponse(response: Response): Promise<Response> {
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,