fix: allowlist Discord CDN hostnames for SSRF media (#33275) (thanks @thewilloftheshadow) (#33275)

This commit is contained in:
Shadow
2026-03-03 11:17:27 -06:00
committed by GitHub
parent bf7061092a
commit a7a9a3d3c8
4 changed files with 129 additions and 13 deletions

View File

@@ -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;

View File

@@ -30,6 +30,22 @@ function asMessage(payload: Record<string, unknown>): 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: "<media:image>" | "<media:sticker>";
}) {
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]),
}),
);
});
});

View File

@@ -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>): 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<DiscordMediaInfo[]> {
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<DiscordMediaInfo[]> {
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,