From eb6bc0651eddc2a29f00acf1f31731e2a201917d Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 20 Aug 2026 20:16:04 -0700 Subject: [PATCH] fix(discord): fence provider-mode asset downloads --- docs/channels/discord.md | 8 ++++--- .../src/monitor/message-avatar.test.ts | 23 +++++++++++++++++++ .../discord/src/monitor/message-avatar.ts | 13 ++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 09a57fca2db4..17b1ba17ac98 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1724,9 +1724,11 @@ openclaw gateway ``` This is a process-wide credential-routing contract, not an account setting. A complete group -redirects REST, Gateway, media, webhook, voice, OAuth, and Activity traffic for every Discord -account in that Gateway. The provider therefore receives Discord credentials and message data; -use only endpoints controlled by the operator. Non-loopback endpoints require HTTPS/WSS. +redirects REST, Gateway, provider-origin media, webhook, voice, OAuth, and Activity traffic for +every Discord account in that Gateway. The provider therefore receives Discord credentials and +message data; use only endpoints controlled by the operator. Non-loopback endpoints require +HTTPS/WSS. Public Discord CDN assets are never fetched in provider mode: provider-origin +attachments remain available, while Discord-generated avatar and sticker assets are skipped. The three values are all-or-none and are read once during Discord startup. Put them in the Gateway supervisor environment or trusted global runtime dotenv, not a workspace `.env`. A diff --git a/extensions/discord/src/monitor/message-avatar.test.ts b/extensions/discord/src/monitor/message-avatar.test.ts index bc43e4d3b542..6fef2336fcd5 100644 --- a/extensions/discord/src/monitor/message-avatar.test.ts +++ b/extensions/discord/src/monitor/message-avatar.test.ts @@ -4,6 +4,7 @@ import type { Client, User } from "../internal/discord.js"; const mocks = vi.hoisted(() => ({ saveRemoteMedia: vi.fn(), logDebug: vi.fn(), + resolveProviderGuard: vi.fn(), })); vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ @@ -14,6 +15,10 @@ vi.mock("openclaw/plugin-sdk/logging-core", () => ({ logDebug: mocks.logDebug, })); +vi.mock("../provider-endpoint.js", () => ({ + resolveDiscordProviderMediaDownloadGuard: mocks.resolveProviderGuard, +})); + const { createDiscordAvatarResolver } = await import("./message-avatar.js"); function deferred() { @@ -35,9 +40,27 @@ const emptyClient = { fetchGuild: vi.fn() } as unknown as Client; beforeEach(() => { mocks.saveRemoteMedia.mockReset(); mocks.logDebug.mockReset(); + mocks.resolveProviderGuard.mockReset(); }); describe("createDiscordAvatarResolver", () => { + it("skips public CDN avatars when provider routing is active", () => { + mocks.resolveProviderGuard.mockImplementation(() => { + throw new Error("Discord provider media URL is outside the configured REST origin"); + }); + const resolver = createDiscordAvatarResolver(); + + expect( + resolver.resolve({ + client: emptyClient, + conversationId: "dm-provider", + author: discordUser("user-1", "hash-1"), + }), + ).toBeUndefined(); + expect(mocks.saveRemoteMedia).not.toHaveBeenCalled(); + expect(mocks.logDebug).toHaveBeenCalledWith(expect.stringContaining("download skipped")); + }); + it("downloads a DM avatar in the background and reuses it until the hash changes", async () => { const firstDownload = deferred<{ path: string }>(); mocks.saveRemoteMedia.mockReturnValueOnce(firstDownload.promise); diff --git a/extensions/discord/src/monitor/message-avatar.ts b/extensions/discord/src/monitor/message-avatar.ts index d002e360edf6..21caf4a1501a 100644 --- a/extensions/discord/src/monitor/message-avatar.ts +++ b/extensions/discord/src/monitor/message-avatar.ts @@ -2,6 +2,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { logDebug } from "openclaw/plugin-sdk/logging-core"; import { saveRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import type { Client, User } from "../internal/discord.js"; +import { resolveDiscordProviderMediaDownloadGuard } from "../provider-endpoint.js"; import { resolveDiscordCdnPolicy } from "./media-ssrf-policy.js"; const DISCORD_AVATAR_MAX_BYTES = 256 * 1024; @@ -46,12 +47,22 @@ export function createDiscordAvatarResolver() { if (pending.has(key) || pending.size >= DISCORD_AVATAR_CACHE_MAX_ENTRIES) { return undefined; } + let providerGuard: ReturnType; + try { + providerGuard = resolveDiscordProviderMediaDownloadGuard(url); + } catch (error) { + logDebug(`discord conversation avatar download skipped: ${formatErrorMessage(error)}`); + return undefined; + } pending.add(key); void saveRemoteMedia({ url, filePathHint: "conversation-avatar.png", maxBytes: DISCORD_AVATAR_MAX_BYTES, - ssrfPolicy: resolveDiscordCdnPolicy(), + ssrfPolicy: providerGuard + ? resolveDiscordCdnPolicy(providerGuard.policy) + : resolveDiscordCdnPolicy(), + ...(providerGuard ? { maxRedirects: providerGuard.maxRedirects } : {}), }) .then((media) => { setBoundedEntry(saved, key, media.path);