From 5b6d40924886085b265f540ad62aebfaef7013fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 19:59:51 +0100 Subject: [PATCH] fix: route Discord gateway metadata through proxy (#86601) * fix: route Discord gateway metadata through proxy * fix: keep Discord gateway proxy fetch guarded --- CHANGELOG.md | 1 + .../discord/src/monitor/gateway-metadata.ts | 24 +++++-- .../discord/src/monitor/gateway-plugin.ts | 30 +++++---- .../src/monitor/provider.proxy.test.ts | 62 ++++++++++++------- 4 files changed, 78 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3283ee4eaa4..c2ac1c486c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct `discord.com` connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker. - Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure. - Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88. - Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc. diff --git a/extensions/discord/src/monitor/gateway-metadata.ts b/extensions/discord/src/monitor/gateway-metadata.ts index 520fac5f9ba9..9bcf6c105c01 100644 --- a/extensions/discord/src/monitor/gateway-metadata.ts +++ b/extensions/discord/src/monitor/gateway-metadata.ts @@ -24,6 +24,10 @@ export type DiscordGatewayFetch = ( input: string, init?: DiscordGatewayFetchInit, ) => Promise; +export type DiscordGatewayMetadataFetchOptions = { + capture?: false | { flowId: string; meta: Record }; + proxyUrl?: string; +}; type DiscordGatewayMetadataError = Error & { transient?: boolean }; @@ -265,10 +269,10 @@ export function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; e }; } -export async function fetchDiscordGatewayMetadataDirect( +export async function fetchDiscordGatewayMetadataGuarded( input: string, init?: DiscordGatewayFetchInit, - capture?: false | { flowId: string; meta: Record }, + options?: DiscordGatewayMetadataFetchOptions, ): Promise { const guarded = await fetchWithSsrFGuard({ url: resolveFetchInputUrl(input), @@ -276,6 +280,16 @@ export async function fetchDiscordGatewayMetadataDirect( policy: { allowedHostnames: [DISCORD_API_HOST] }, capture: false, auditContext: "discord.gateway.metadata", + ...(options?.proxyUrl + ? { + mode: "trusted_explicit_proxy" as const, + dispatcherPolicy: { + mode: "explicit-proxy" as const, + proxyUrl: options.proxyUrl, + allowPrivateProxy: true, + }, + } + : {}), }); let response: Response; try { @@ -283,15 +297,15 @@ export async function fetchDiscordGatewayMetadataDirect( } finally { await guarded.release(); } - if (capture) { + if (options?.capture) { captureHttpExchange({ url: input, method: (init?.method as string | undefined) ?? "GET", requestHeaders: init?.headers as Headers | Record | undefined, requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, response, - flowId: capture.flowId, - meta: capture.meta, + flowId: options.capture.flowId, + meta: options.capture.meta, }); } return response; diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 635af23a60c2..95e32a09ff9f 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -17,7 +17,7 @@ import { resolveDiscordVoiceEnabled } from "../voice/config.js"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; import { fetchDiscordGatewayInfoWithTimeout, - fetchDiscordGatewayMetadataDirect, + fetchDiscordGatewayMetadataGuarded, resolveDiscordGatewayInfoTimeoutMs, resolveGatewayInfoWithFallback, type DiscordGatewayFetch, @@ -230,18 +230,22 @@ function createGatewayPlugin(params: { return new OpenClawGatewayPlugin(); } -function createDiscordGatewayMetadataFetch(debugCaptureEnabled: boolean): DiscordGatewayFetch { +function createDiscordGatewayMetadataFetch( + debugCaptureEnabled: boolean, + proxyUrl?: string, +): DiscordGatewayFetch { return (input, init) => - fetchDiscordGatewayMetadataDirect( - input, - init, - debugCaptureEnabled - ? false + fetchDiscordGatewayMetadataGuarded(input, init, { + ...(debugCaptureEnabled + ? {} : { - flowId: randomUUID(), - meta: { subsystem: "discord-gateway-metadata" }, - }, - ); + capture: { + flowId: randomUUID(), + meta: { subsystem: "discord-gateway-metadata" }, + }, + }), + ...(proxyUrl ? { proxyUrl } : {}), + }); } export function waitForDiscordGatewayPluginRegistration( @@ -279,10 +283,12 @@ export function createDiscordGatewayPlugin(params: { const HttpsProxyAgentCtor = params.testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; wsAgent = new HttpsProxyAgentCtor(proxy); + fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled, proxy); params.runtime.log?.("discord: gateway proxy enabled"); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); - fetchImpl = (input, init) => fetchDiscordGatewayMetadataDirect(input, init, false); + fetchImpl = (input, init) => + fetchDiscordGatewayMetadataGuarded(input, init, { capture: false }); } } diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index b20f93c6f5f3..8eb793e20bd4 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -37,6 +37,7 @@ const { globalFetchMock, HttpsAgent, HttpsProxyAgent, + fetchWithSsrFGuardMock, getLastAgent, getLastProxyAgent, resolveDebugProxySettingsMock, @@ -53,6 +54,18 @@ const { const captureHttpExchangeSpy = vi.fn(); const captureWsEventSpy = vi.fn(); const resolveDebugProxySettingsMock = vi.fn(() => ({ enabled: false })); + const fetchWithSsrFGuardMock = vi.fn(async (params: { url: string; init?: RequestInit }) => { + const source = (await globalFetchMock(params.url, params.init)) as Response; + const body = await source.text(); + return { + response: new Response(body, { + status: source.status, + statusText: source.statusText, + headers: source.headers, + }), + release: vi.fn(), + }; + }); const GatewayIntents = { Guilds: 1 << 0, @@ -114,6 +127,7 @@ const { globalFetchMock, HttpsAgent, HttpsProxyAgent, + fetchWithSsrFGuardMock, getLastAgent: () => HttpsAgent.lastCreated, getLastProxyAgent: () => HttpsProxyAgent.lastCreated, captureHttpExchangeSpy, @@ -166,18 +180,7 @@ vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({ })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => { - const source = (await globalFetchMock(params.url, params.init)) as Response; - const body = await source.text(); - return { - response: new Response(body, { - status: source.status, - statusText: source.statusText, - headers: source.headers, - }), - release: vi.fn(), - }; - }), + fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); describe("createDiscordGatewayPlugin", () => { @@ -213,6 +216,16 @@ describe("createDiscordGatewayPlugin", () => { return firstMockCall(mock, label)[index]; } + function firstGuardedFetchCall() { + return firstMockArg(fetchWithSsrFGuardMock, "fetchWithSsrFGuardMock") as { + url: string; + init?: RequestInit & { signal?: unknown }; + mode?: string; + dispatcherPolicy?: unknown; + policy?: unknown; + }; + } + function createProxyTestingOverrides() { return { HttpsProxyAgentCtor: @@ -318,6 +331,7 @@ describe("createDiscordGatewayPlugin", () => { vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); + fetchWithSsrFGuardMock.mockClear(); httpsAgentSpy.mockClear(); wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); @@ -504,9 +518,10 @@ describe("createDiscordGatewayPlugin", () => { expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype); expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); }); - it("keeps gateway metadata lookup on the guarded direct fetch when proxy is configured", async () => { + it("routes gateway metadata lookup through the guarded proxy dispatcher", async () => { const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://127.0.0.1:8080" }, @@ -516,15 +531,18 @@ describe("createDiscordGatewayPlugin", () => { await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock }); - expect(globalFetchMock).toHaveBeenCalledTimes(1); - const fetchInit = firstMockArg(globalFetchMock, "globalFetchMock", 1) as - | { headers?: Record; signal?: unknown } - | undefined; - expect(firstMockArg(globalFetchMock, "globalFetchMock")).toBe( - "https://discord.com/api/v10/gateway/bot", - ); - expect(fetchInit?.headers).toEqual({ Authorization: "Bot token-123" }); - expect(fetchInit?.signal).toBeInstanceOf(AbortSignal); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); + const guardedFetch = firstGuardedFetchCall(); + expect(guardedFetch.url).toBe("https://discord.com/api/v10/gateway/bot"); + expect(guardedFetch.mode).toBe("trusted_explicit_proxy"); + expect(guardedFetch.dispatcherPolicy).toEqual({ + mode: "explicit-proxy", + proxyUrl: "http://127.0.0.1:8080", + allowPrivateProxy: true, + }); + expect(guardedFetch.policy).toEqual({ allowedHostnames: ["discord.com"] }); + expect(guardedFetch.init?.headers).toEqual({ Authorization: "Bot token-123" }); + expect(guardedFetch.init?.signal).toBeInstanceOf(AbortSignal); expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); });