diff --git a/CHANGELOG.md b/CHANGELOG.md index 899064a48..aac19b9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. - Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow. - Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow. +- Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow. - Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf - macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky. diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 646643782..34473614b 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -104,11 +104,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) discordRestFetch, } = ctx; - const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch); + const ssrfPolicy = cfg.browser?.ssrfPolicy; + const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch, ssrfPolicy); const forwardedMediaList = await resolveForwardedMediaList( message, mediaMaxBytes, discordRestFetch, + ssrfPolicy, ); mediaList.push(...forwardedMediaList); const text = messageText; diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 2352d2f86..f4c5be256 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -30,6 +30,22 @@ function asMessage(payload: Record): Message { return payload as unknown as Message; } +const DISCORD_CDN_HOSTNAMES = [ + "cdn.discordapp.com", + "media.discordapp.net", + "*.discordapp.com", + "*.discordapp.net", +]; + +function expectDiscordCdnSsrFPolicy(policy: unknown) { + expect(policy).toEqual( + expect.objectContaining({ + allowRfc2544BenchmarkRange: true, + hostnameAllowlist: expect.arrayContaining(DISCORD_CDN_HOSTNAMES), + }), + ); +} + function expectSinglePngDownload(params: { result: unknown; expectedUrl: string; @@ -38,13 +54,20 @@ function expectSinglePngDownload(params: { placeholder: "" | ""; }) { expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); - expect(fetchRemoteMedia).toHaveBeenCalledWith({ + const call = fetchRemoteMedia.mock.calls[0]?.[0] as { + url?: string; + filePathHint?: string; + maxBytes?: number; + fetchImpl?: unknown; + ssrfPolicy?: unknown; + }; + expect(call).toMatchObject({ url: params.expectedUrl, filePathHint: params.filePathHint, maxBytes: 512, fetchImpl: undefined, - ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }), }); + expectDiscordCdnSsrFPolicy(call.ssrfPolicy); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); expect(params.result).toEqual([ @@ -151,13 +174,20 @@ describe("resolveForwardedMediaList", () => { ); expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); - expect(fetchRemoteMedia).toHaveBeenCalledWith({ + const call = fetchRemoteMedia.mock.calls[0]?.[0] as { + url?: string; + filePathHint?: string; + maxBytes?: number; + fetchImpl?: unknown; + ssrfPolicy?: unknown; + }; + expect(call).toMatchObject({ url: attachment.url, filePathHint: attachment.filename, maxBytes: 512, fetchImpl: undefined, - ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }), }); + expectDiscordCdnSsrFPolicy(call.ssrfPolicy); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); expect(result).toEqual([ @@ -471,7 +501,7 @@ describe("Discord media SSRF policy", () => { saveMediaBuffer.mockClear(); }); - it("passes ssrfPolicy with Discord CDN allowedHostnames and allowRfc2544BenchmarkRange", async () => { + it("passes Discord CDN hostname allowlist with RFC2544 enabled", async () => { fetchRemoteMedia.mockResolvedValueOnce({ buffer: Buffer.from("img"), contentType: "image/png", @@ -488,11 +518,42 @@ describe("Discord media SSRF policy", () => { 1024, ); - const policy = fetchRemoteMedia.mock.calls[0][0].ssrfPolicy; - expect(policy).toEqual({ - allowedHostnames: ["cdn.discordapp.com", "media.discordapp.net"], - allowRfc2544BenchmarkRange: true, + const policy = fetchRemoteMedia.mock.calls[0]?.[0]?.ssrfPolicy; + expectDiscordCdnSsrFPolicy(policy); + }); + + it("merges provided ssrfPolicy with Discord CDN defaults", async () => { + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("img"), + contentType: "image/png", }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/b.png", + contentType: "image/png", + }); + + await resolveMediaList( + asMessage({ + attachments: [{ id: "b1", url: "https://cdn.discordapp.com/b.png", filename: "b.png" }], + }), + 1024, + undefined, + { + allowPrivateNetwork: true, + hostnameAllowlist: ["assets.example.com"], + allowedHostnames: ["assets.example.com"], + }, + ); + + const policy = fetchRemoteMedia.mock.calls[0]?.[0]?.ssrfPolicy; + expect(policy).toEqual( + expect.objectContaining({ + allowPrivateNetwork: true, + allowRfc2544BenchmarkRange: true, + allowedHostnames: expect.arrayContaining(["assets.example.com"]), + hostnameAllowlist: expect.arrayContaining(["assets.example.com", ...DISCORD_CDN_HOSTNAMES]), + }), + ); }); }); diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 0be421ecb..b26f8d68e 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -6,11 +6,53 @@ import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; +const DISCORD_CDN_HOSTNAMES = [ + "cdn.discordapp.com", + "media.discordapp.net", + "*.discordapp.com", + "*.discordapp.net", +]; + +// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges. const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = { - allowedHostnames: ["cdn.discordapp.com", "media.discordapp.net"], + hostnameAllowlist: DISCORD_CDN_HOSTNAMES, allowRfc2544BenchmarkRange: true, }; +function mergeHostnameList(...lists: Array): string[] | undefined { + const merged = lists + .flatMap((list) => list ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (merged.length === 0) { + return undefined; + } + return Array.from(new Set(merged)); +} + +function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy { + if (!policy) { + return DISCORD_MEDIA_SSRF_POLICY; + } + const hostnameAllowlist = mergeHostnameList( + DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist, + policy.hostnameAllowlist, + ); + const allowedHostnames = mergeHostnameList( + DISCORD_MEDIA_SSRF_POLICY.allowedHostnames, + policy.allowedHostnames, + ); + return { + ...DISCORD_MEDIA_SSRF_POLICY, + ...policy, + ...(allowedHostnames ? { allowedHostnames } : {}), + ...(hostnameAllowlist ? { hostnameAllowlist } : {}), + allowRfc2544BenchmarkRange: + Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) || + Boolean(policy.allowRfc2544BenchmarkRange), + }; +} + export type DiscordMediaInfo = { path: string; contentType?: string; @@ -168,14 +210,17 @@ export async function resolveMediaList( message: Message, maxBytes: number, fetchImpl?: FetchLike, + ssrfPolicy?: SsrFPolicy, ): Promise { const out: DiscordMediaInfo[] = []; + const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy); await appendResolvedMediaFromAttachments({ attachments: message.attachments ?? [], maxBytes, out, errorPrefix: "discord: failed to download attachment", fetchImpl, + ssrfPolicy: resolvedSsrFPolicy, }); await appendResolvedMediaFromStickers({ stickers: resolveDiscordMessageStickers(message), @@ -183,6 +228,7 @@ export async function resolveMediaList( out, errorPrefix: "discord: failed to download sticker", fetchImpl, + ssrfPolicy: resolvedSsrFPolicy, }); return out; } @@ -191,12 +237,14 @@ export async function resolveForwardedMediaList( message: Message, maxBytes: number, fetchImpl?: FetchLike, + ssrfPolicy?: SsrFPolicy, ): Promise { const snapshots = resolveDiscordMessageSnapshots(message); if (snapshots.length === 0) { return []; } const out: DiscordMediaInfo[] = []; + const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy); for (const snapshot of snapshots) { await appendResolvedMediaFromAttachments({ attachments: snapshot.message?.attachments, @@ -204,6 +252,7 @@ export async function resolveForwardedMediaList( out, errorPrefix: "discord: failed to download forwarded attachment", fetchImpl, + ssrfPolicy: resolvedSsrFPolicy, }); await appendResolvedMediaFromStickers({ stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], @@ -211,6 +260,7 @@ export async function resolveForwardedMediaList( out, errorPrefix: "discord: failed to download forwarded sticker", fetchImpl, + ssrfPolicy: resolvedSsrFPolicy, }); } return out; @@ -222,6 +272,7 @@ async function appendResolvedMediaFromAttachments(params: { out: DiscordMediaInfo[]; errorPrefix: string; fetchImpl?: FetchLike; + ssrfPolicy?: SsrFPolicy; }) { const attachments = params.attachments; if (!attachments || attachments.length === 0) { @@ -234,7 +285,7 @@ async function appendResolvedMediaFromAttachments(params: { filePathHint: attachment.filename ?? attachment.url, maxBytes: params.maxBytes, fetchImpl: params.fetchImpl, - ssrfPolicy: DISCORD_MEDIA_SSRF_POLICY, + ssrfPolicy: params.ssrfPolicy, }); const saved = await saveMediaBuffer( fetched.buffer, @@ -331,6 +382,7 @@ async function appendResolvedMediaFromStickers(params: { out: DiscordMediaInfo[]; errorPrefix: string; fetchImpl?: FetchLike; + ssrfPolicy?: SsrFPolicy; }) { const stickers = params.stickers; if (!stickers || stickers.length === 0) { @@ -346,7 +398,7 @@ async function appendResolvedMediaFromStickers(params: { filePathHint: candidate.fileName, maxBytes: params.maxBytes, fetchImpl: params.fetchImpl, - ssrfPolicy: DISCORD_MEDIA_SSRF_POLICY, + ssrfPolicy: params.ssrfPolicy, }); const saved = await saveMediaBuffer( fetched.buffer,