This commit is contained in:
parent
bf7061092a
commit
a7a9a3d3c8
@ -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://<peer>.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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user