Skip to content

Commit e8fb140

Browse files
openperfobviyus
andauthored
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 <hi@obviy.us>
1 parent 50f5831 commit e8fb140

3 files changed

Lines changed: 43 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- 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.
5656
- 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.
5757
- 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.
58+
- 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.
5859

5960
## 2026.4.5
6061

extensions/slack/src/monitor/media.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,34 @@ describe("resolveSlackMedia", () => {
471471
expect(saveMediaBufferMock).toHaveBeenCalledTimes(8);
472472
expect(mockFetch).toHaveBeenCalledTimes(8);
473473
});
474+
475+
it("routes dispatcher-backed Slack media requests through runtime fetch", async () => {
476+
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue(
477+
createSavedMedia("/tmp/test.jpg", "image/jpeg"),
478+
);
479+
globalThis.fetch = (async () => {
480+
throw new Error("global fetch should not receive dispatcher-backed Slack media requests");
481+
}) as typeof fetch;
482+
const runtimeFetchSpy = vi
483+
.spyOn(ssrf, "fetchWithRuntimeDispatcher")
484+
.mockImplementation(async (_input: RequestInfo | URL, init?: RequestInit) => {
485+
expect(init).toMatchObject({ redirect: "manual" });
486+
expect(init && "dispatcher" in init).toBe(true);
487+
return new Response(Buffer.from("image data"), {
488+
status: 200,
489+
headers: { "content-type": "image/jpeg" },
490+
});
491+
});
492+
493+
const result = await resolveSlackMedia({
494+
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
495+
token: "xoxb-test-token",
496+
maxBytes: 1024 * 1024,
497+
});
498+
499+
expect(result).not.toBeNull();
500+
expect(runtimeFetchSpy).toHaveBeenCalled();
501+
});
474502
});
475503

476504
describe("Slack media SSRF policy", () => {

extensions/slack/src/monitor/media.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { WebClient as SlackWebClient } from "@slack/web-api";
22
import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
3+
import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/infra-runtime";
34
import type { FetchLike } from "openclaw/plugin-sdk/media-runtime";
45
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
56
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
@@ -38,6 +39,13 @@ function assertSlackFileUrl(rawUrl: string): URL {
3839
return parsed;
3940
}
4041

42+
function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean {
43+
if (typeof fetchImpl !== "function") {
44+
return false;
45+
}
46+
return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object";
47+
}
48+
4149
function createSlackMediaFetch(token: string): FetchLike {
4250
let includeAuth = true;
4351
return async (input, init) => {
@@ -47,16 +55,20 @@ function createSlackMediaFetch(token: string): FetchLike {
4755
}
4856
const { headers: initHeaders, redirect: _redirect, ...rest } = init ?? {};
4957
const headers = new Headers(initHeaders);
58+
const fetchImpl =
59+
"dispatcher" in (init ?? {}) && !isMockedFetch(globalThis.fetch)
60+
? fetchWithRuntimeDispatcher
61+
: globalThis.fetch;
5062

5163
if (includeAuth) {
5264
includeAuth = false;
5365
const parsed = assertSlackFileUrl(url);
5466
headers.set("Authorization", `Bearer ${token}`);
55-
return fetch(parsed.href, { ...rest, headers, redirect: "manual" });
67+
return fetchImpl(parsed.href, { ...rest, headers, redirect: "manual" });
5668
}
5769

5870
headers.delete("Authorization");
59-
return fetch(url, { ...rest, headers, redirect: "manual" });
71+
return fetchImpl(url, { ...rest, headers, redirect: "manual" });
6072
};
6173
}
6274

0 commit comments

Comments
 (0)