mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 22:20:21 +00:00
fix(discord,slack): add SSRF policy for media downloads in proxy environments (#25475)
* fix(discord,slack): add SSRF policy for media downloads in proxy environments Discord and Slack media downloads (attachments, stickers, forwarded images) call fetchRemoteMedia without any ssrfPolicy. When running behind a local transparent proxy (Clash, mihomo, Shadowrocket) in fake-ip mode, DNS returns virtual IPs in the 198.18.0.0/15 range, which the SSRF guard blocks. Add per-channel SSRF policy constants—matching the pattern already applied to Telegram on main—that allowlist known CDN hostnames and set allowRfc2544BenchmarkRange: true. Refs #25355, #25322 Co-authored-by: Cursor <cursoragent@cursor.com> * chore(slack): keep raw-fetch allowlist line anchors stable --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -94,6 +94,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filePathHint: attachment.filename,
|
filePathHint: attachment.filename,
|
||||||
maxBytes: 512,
|
maxBytes: 512,
|
||||||
fetchImpl: undefined,
|
fetchImpl: undefined,
|
||||||
|
ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }),
|
||||||
});
|
});
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
||||||
@@ -168,6 +169,7 @@ describe("resolveForwardedMediaList", () => {
|
|||||||
filePathHint: "wave.png",
|
filePathHint: "wave.png",
|
||||||
maxBytes: 512,
|
maxBytes: 512,
|
||||||
fetchImpl: undefined,
|
fetchImpl: undefined,
|
||||||
|
ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }),
|
||||||
});
|
});
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
||||||
@@ -236,6 +238,7 @@ describe("resolveMediaList", () => {
|
|||||||
filePathHint: "hello.png",
|
filePathHint: "hello.png",
|
||||||
maxBytes: 512,
|
maxBytes: 512,
|
||||||
fetchImpl: undefined,
|
fetchImpl: undefined,
|
||||||
|
ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }),
|
||||||
});
|
});
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
||||||
@@ -278,6 +281,37 @@ describe("resolveMediaList", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Discord media SSRF policy", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchRemoteMedia.mockClear();
|
||||||
|
saveMediaBuffer.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes ssrfPolicy with Discord CDN allowedHostnames and allowRfc2544BenchmarkRange", async () => {
|
||||||
|
fetchRemoteMedia.mockResolvedValueOnce({
|
||||||
|
buffer: Buffer.from("img"),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
saveMediaBuffer.mockResolvedValueOnce({
|
||||||
|
path: "/tmp/a.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveMediaList(
|
||||||
|
asMessage({
|
||||||
|
attachments: [{ id: "a1", url: "https://cdn.discordapp.com/a.png", filename: "a.png" }],
|
||||||
|
}),
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
|
||||||
|
const policy = fetchRemoteMedia.mock.calls[0][0].ssrfPolicy;
|
||||||
|
expect(policy).toEqual({
|
||||||
|
allowedHostnames: ["cdn.discordapp.com", "media.discordapp.net"],
|
||||||
|
allowRfc2544BenchmarkRange: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveDiscordMessageText", () => {
|
describe("resolveDiscordMessageText", () => {
|
||||||
it("includes forwarded message snapshots in body text", () => {
|
it("includes forwarded message snapshots in body text", () => {
|
||||||
const text = resolveDiscordMessageText(
|
const text = resolveDiscordMessageText(
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import type { ChannelType, Client, Message } from "@buape/carbon";
|
|||||||
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
||||||
import { buildMediaPayload } from "../../channels/plugins/media-payload.js";
|
import { buildMediaPayload } from "../../channels/plugins/media-payload.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
|
||||||
import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js";
|
import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js";
|
||||||
import { saveMediaBuffer } from "../../media/store.js";
|
import { saveMediaBuffer } from "../../media/store.js";
|
||||||
|
|
||||||
|
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
||||||
|
allowedHostnames: ["cdn.discordapp.com", "media.discordapp.net"],
|
||||||
|
allowRfc2544BenchmarkRange: true,
|
||||||
|
};
|
||||||
|
|
||||||
export type DiscordMediaInfo = {
|
export type DiscordMediaInfo = {
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -228,6 +234,7 @@ async function appendResolvedMediaFromAttachments(params: {
|
|||||||
filePathHint: attachment.filename ?? attachment.url,
|
filePathHint: attachment.filename ?? attachment.url,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
|
ssrfPolicy: DISCORD_MEDIA_SSRF_POLICY,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
@@ -320,6 +327,7 @@ async function appendResolvedMediaFromStickers(params: {
|
|||||||
filePathHint: candidate.fileName,
|
filePathHint: candidate.fileName,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
|
ssrfPolicy: DISCORD_MEDIA_SSRF_POLICY,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as ssrf from "../../infra/net/ssrf.js";
|
import * as ssrf from "../../infra/net/ssrf.js";
|
||||||
|
import * as mediaFetch from "../../media/fetch.js";
|
||||||
import type { SavedMedia } from "../../media/store.js";
|
import type { SavedMedia } from "../../media/store.js";
|
||||||
import * as mediaStore from "../../media/store.js";
|
import * as mediaStore from "../../media/store.js";
|
||||||
import { mockPinnedHostnameResolution } from "../../test-helpers/ssrf.js";
|
import { mockPinnedHostnameResolution } from "../../test-helpers/ssrf.js";
|
||||||
@@ -472,6 +473,80 @@ describe("resolveSlackMedia", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Slack media SSRF policy", () => {
|
||||||
|
const originalFetchLocal = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
globalThis.fetch = withFetchPreconnect(mockFetch);
|
||||||
|
mockPinnedHostnameResolution();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetchLocal;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes ssrfPolicy with Slack CDN allowedHostnames and allowRfc2544BenchmarkRange to file downloads", async () => {
|
||||||
|
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue(
|
||||||
|
createSavedMedia("/tmp/test.jpg", "image/jpeg"),
|
||||||
|
);
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response(Buffer.from("img"), { status: 200, headers: { "content-type": "image/jpeg" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(mediaFetch, "fetchRemoteMedia");
|
||||||
|
|
||||||
|
await resolveSlackMedia({
|
||||||
|
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
|
||||||
|
token: "xoxb-test-token",
|
||||||
|
maxBytes: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const policy = spy.mock.calls[0][0].ssrfPolicy;
|
||||||
|
expect(policy?.allowedHostnames).toEqual(
|
||||||
|
expect.arrayContaining(["*.slack.com", "*.slack-edge.com", "*.slack-files.com"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes ssrfPolicy to forwarded attachment image downloads", async () => {
|
||||||
|
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue(
|
||||||
|
createSavedMedia("/tmp/fwd.jpg", "image/jpeg"),
|
||||||
|
);
|
||||||
|
vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
return {
|
||||||
|
hostname: normalized,
|
||||||
|
addresses: ["93.184.216.34"],
|
||||||
|
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses: ["93.184.216.34"] }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response(Buffer.from("fwd"), { status: 200, headers: { "content-type": "image/jpeg" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(mediaFetch, "fetchRemoteMedia");
|
||||||
|
|
||||||
|
await resolveSlackAttachmentContent({
|
||||||
|
attachments: [{ is_share: true, image_url: "https://files.slack.com/forwarded.jpg" }],
|
||||||
|
token: "xoxb-test-token",
|
||||||
|
maxBytes: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ssrfPolicy: expect.objectContaining({ allowRfc2544BenchmarkRange: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveSlackAttachmentContent", () => {
|
describe("resolveSlackAttachmentContent", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch = vi.fn();
|
mockFetch = vi.fn();
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise<Re
|
|||||||
return fetch(resolvedUrl.toString(), { redirect: "follow" });
|
return fetch(resolvedUrl.toString(), { redirect: "follow" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SLACK_MEDIA_SSRF_POLICY = {
|
||||||
|
allowedHostnames: ["*.slack.com", "*.slack-edge.com", "*.slack-files.com"],
|
||||||
|
allowRfc2544BenchmarkRange: true,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slack voice messages (audio clips, huddle recordings) carry a `subtype` of
|
* Slack voice messages (audio clips, huddle recordings) carry a `subtype` of
|
||||||
* `"slack_audio"` but are served with a `video/*` MIME type (e.g. `video/mp4`,
|
* `"slack_audio"` but are served with a `video/*` MIME type (e.g. `video/mp4`,
|
||||||
@@ -218,6 +223,7 @@ export async function resolveSlackMedia(params: {
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
filePathHint: file.name,
|
filePathHint: file.name,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
|
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
|
||||||
});
|
});
|
||||||
if (fetched.buffer.byteLength > params.maxBytes) {
|
if (fetched.buffer.byteLength > params.maxBytes) {
|
||||||
return null;
|
return null;
|
||||||
@@ -297,6 +303,7 @@ export async function resolveSlackAttachmentContent(params: {
|
|||||||
url: imageUrl,
|
url: imageUrl,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
|
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
|
||||||
});
|
});
|
||||||
if (fetched.buffer.byteLength <= params.maxBytes) {
|
if (fetched.buffer.byteLength <= params.maxBytes) {
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await saveMediaBuffer(
|
||||||
|
|||||||
Reference in New Issue
Block a user