From e8fb14064207ab4f1a271e44bba2e56e485f8954 Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:45:53 +0800 Subject: [PATCH] fix: preserve Slack guarded media transport (#62239) (thanks @openperf) * fix(slack ): prevent undici dispatcher leak to globalThis.fetch causing media download failure * fix(slack): preserve guarded media transport * fix: preserve Slack guarded media transport (#62239) (thanks @openperf) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + extensions/slack/src/monitor/media.test.ts | 28 ++++++++++++++++++++++ extensions/slack/src/monitor/media.ts | 16 +++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c192d95ceb4..f02c77f5b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras. - Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus. - Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla. +- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf. ## 2026.4.5 diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 072200ee545..705a74f7d00 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -471,6 +471,34 @@ describe("resolveSlackMedia", () => { expect(saveMediaBufferMock).toHaveBeenCalledTimes(8); expect(mockFetch).toHaveBeenCalledTimes(8); }); + + it("routes dispatcher-backed Slack media requests through runtime fetch", async () => { + vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue( + createSavedMedia("/tmp/test.jpg", "image/jpeg"), + ); + globalThis.fetch = (async () => { + throw new Error("global fetch should not receive dispatcher-backed Slack media requests"); + }) as typeof fetch; + const runtimeFetchSpy = vi + .spyOn(ssrf, "fetchWithRuntimeDispatcher") + .mockImplementation(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(init).toMatchObject({ redirect: "manual" }); + expect(init && "dispatcher" in init).toBe(true); + return new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + }); + + const result = await resolveSlackMedia({ + files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(runtimeFetchSpy).toHaveBeenCalled(); + }); }); describe("Slack media SSRF policy", () => { diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index 22e6ee2b9d9..75da10d46f3 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,5 +1,6 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime"; +import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/infra-runtime"; import type { FetchLike } from "openclaw/plugin-sdk/media-runtime"; import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; @@ -38,6 +39,13 @@ function assertSlackFileUrl(rawUrl: string): URL { return parsed; } +function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean { + if (typeof fetchImpl !== "function") { + return false; + } + return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object"; +} + function createSlackMediaFetch(token: string): FetchLike { let includeAuth = true; return async (input, init) => { @@ -47,16 +55,20 @@ function createSlackMediaFetch(token: string): FetchLike { } const { headers: initHeaders, redirect: _redirect, ...rest } = init ?? {}; const headers = new Headers(initHeaders); + const fetchImpl = + "dispatcher" in (init ?? {}) && !isMockedFetch(globalThis.fetch) + ? fetchWithRuntimeDispatcher + : globalThis.fetch; if (includeAuth) { includeAuth = false; const parsed = assertSlackFileUrl(url); headers.set("Authorization", `Bearer ${token}`); - return fetch(parsed.href, { ...rest, headers, redirect: "manual" }); + return fetchImpl(parsed.href, { ...rest, headers, redirect: "manual" }); } headers.delete("Authorization"); - return fetch(url, { ...rest, headers, redirect: "manual" }); + return fetchImpl(url, { ...rest, headers, redirect: "manual" }); }; }