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>
This commit is contained in:
Chunyue Wang
2026-04-07 13:45:53 +08:00
committed by GitHub
parent 50f5831382
commit e8fb140642
3 changed files with 43 additions and 2 deletions

View File

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

View File

@@ -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", () => {

View File

@@ -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" });
};
}