Moderate severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.2 - SSRF via Attachment Media URL Hydration
CVE-2026-28467
Description
OpenClaw versions prior to 2026.2.2 contain a server-side request forgery vulnerability in attachment and media URL hydration that allows remote attackers to fetch arbitrary HTTP(S) URLs. Attackers who can influence media URLs through model-controlled sendAttachment or auto-reply mechanisms can trigger SSRF to internal resources and exfiltrate fetched response bytes as outbound attachments.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.2 | 2026.2.2 |
Affected products
1Patches
29bd64c8a1f91fix: expand SSRF guard coverage
14 files changed · +214 −96
CHANGELOG.md+7 −0 modified@@ -2,6 +2,13 @@ Docs: https://docs.openclaw.ai +## 2026.2.2 + +### Fixes + +- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). +- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. + ## 2026.2.1 ### Changes
src/agents/skills-install.ts+6 −4 modified@@ -5,6 +5,7 @@ import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { OpenClawConfig } from "../config/config.js"; import { resolveBrewExecutable } from "../infra/brew.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js"; import { @@ -176,10 +177,11 @@ async function downloadFile( destPath: string, timeoutMs: number, ): Promise<{ bytes: number }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs)); + const { response, release } = await fetchWithSsrFGuard({ + url, + timeoutMs: Math.max(1_000, timeoutMs), + }); try { - const response = await fetch(url, { signal: controller.signal }); if (!response.ok || !response.body) { throw new Error(`Download failed (${response.status} ${response.statusText})`); } @@ -193,7 +195,7 @@ async function downloadFile( const stat = await fs.promises.stat(destPath); return { bytes: stat.size }; } finally { - clearTimeout(timeout); + await release(); } }
src/agents/tools/web-fetch.ts+6 −4 modified@@ -394,10 +394,12 @@ async function runWebFetch(params: { url: params.url, maxRedirects: params.maxRedirects, timeoutMs: params.timeoutSeconds * 1000, - headers: { - Accept: "*/*", - "User-Agent": params.userAgent, - "Accept-Language": "en-US,en;q=0.9", + init: { + headers: { + Accept: "*/*", + "User-Agent": params.userAgent, + "Accept-Language": "en-US,en;q=0.9", + }, }, }); res = result.response;
src/infra/net/fetch-guard.ts+7 −6 modified@@ -13,13 +13,13 @@ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo export type GuardedFetchOptions = { url: string; fetchImpl?: FetchLike; - method?: string; - headers?: HeadersInit; + init?: RequestInit; maxRedirects?: number; timeoutMs?: number; signal?: AbortSignal; policy?: SsrFPolicy; lookupFn?: LookupFn; + pinDns?: boolean; }; export type GuardedFetchResult = { @@ -122,13 +122,14 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G policy: params.policy, }) : await resolvePinnedHostname(parsedUrl.hostname, params.lookupFn); - dispatcher = createPinnedDispatcher(pinned); + if (params.pinDns !== false) { + dispatcher = createPinnedDispatcher(pinned); + } const init: RequestInit & { dispatcher?: Dispatcher } = { + ...(params.init ? { ...params.init } : {}), redirect: "manual", - dispatcher, - ...(params.method ? { method: params.method } : {}), - ...(params.headers ? { headers: params.headers } : {}), + ...(dispatcher ? { dispatcher } : {}), ...(signal ? { signal } : {}), };
src/media/input-files.ts+1 −1 modified@@ -146,7 +146,7 @@ export async function fetchWithGuard(params: { url: params.url, maxRedirects: params.maxRedirects, timeoutMs: params.timeoutMs, - headers: { "User-Agent": "OpenClaw-Gateway/1.0" }, + init: { headers: { "User-Agent": "OpenClaw-Gateway/1.0" } }, }); try {
src/media-understanding/providers/deepgram/audio.test.ts+28 −1 modified@@ -1,6 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../../infra/net/ssrf.js"; import { transcribeDeepgramAudio } from "./audio.js"; +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const resolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; +const lookupMock = vi.fn(); +let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn> | null = null; +let resolvePinnedHostnameWithPolicySpy: ReturnType<typeof vi.spyOn> | null = null; + const resolveRequestUrl = (input: RequestInfo | URL) => { if (typeof input === "string") { return input; @@ -12,6 +19,26 @@ const resolveRequestUrl = (input: RequestInfo | URL) => { }; describe("transcribeDeepgramAudio", () => { + beforeEach(() => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); + resolvePinnedHostnameWithPolicySpy = vi + .spyOn(ssrf, "resolvePinnedHostnameWithPolicy") + .mockImplementation((hostname, params) => + resolvePinnedHostnameWithPolicy(hostname, { ...params, lookupFn: lookupMock }), + ); + }); + + afterEach(() => { + lookupMock.mockReset(); + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameWithPolicySpy?.mockRestore(); + resolvePinnedHostnameSpy = null; + resolvePinnedHostnameWithPolicySpy = null; + }); + it("respects lowercase authorization header overrides", async () => { let seenAuth: string | null = null; const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
src/media-understanding/providers/deepgram/audio.ts+18 −12 modified@@ -1,5 +1,5 @@ import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; -import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; +import { fetchWithTimeoutGuarded, normalizeBaseUrl, readErrorResponse } from "../shared.js"; export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = "https://api.deepgram.com/v1"; export const DEFAULT_DEEPGRAM_AUDIO_MODEL = "nova-3"; @@ -24,6 +24,7 @@ export async function transcribeDeepgramAudio( ): Promise<AudioTranscriptionResult> { const fetchFn = params.fetchFn ?? fetch; const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_DEEPGRAM_AUDIO_BASE_URL); + const allowPrivate = Boolean(params.baseUrl?.trim()); const model = resolveModel(params.model); const url = new URL(`${baseUrl}/listen`); @@ -49,7 +50,7 @@ export async function transcribeDeepgramAudio( } const body = new Uint8Array(params.buffer); - const res = await fetchWithTimeout( + const { response: res, release } = await fetchWithTimeoutGuarded( url.toString(), { method: "POST", @@ -58,18 +59,23 @@ export async function transcribeDeepgramAudio( }, params.timeoutMs, fetchFn, + allowPrivate ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined, ); - if (!res.ok) { - const detail = await readErrorResponse(res); - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); - } + try { + if (!res.ok) { + const detail = await readErrorResponse(res); + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); + } - const payload = (await res.json()) as DeepgramTranscriptResponse; - const transcript = payload.results?.channels?.[0]?.alternatives?.[0]?.transcript?.trim(); - if (!transcript) { - throw new Error("Audio transcription response missing transcript"); + const payload = (await res.json()) as DeepgramTranscriptResponse; + const transcript = payload.results?.channels?.[0]?.alternatives?.[0]?.transcript?.trim(); + if (!transcript) { + throw new Error("Audio transcription response missing transcript"); + } + return { text: transcript, model }; + } finally { + await release(); } - return { text: transcript, model }; }
src/media-understanding/providers/google/audio.ts+26 −20 modified@@ -1,6 +1,6 @@ import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js"; -import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; +import { fetchWithTimeoutGuarded, normalizeBaseUrl, readErrorResponse } from "../shared.js"; export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; @@ -24,6 +24,7 @@ export async function transcribeGeminiAudio( ): Promise<AudioTranscriptionResult> { const fetchFn = params.fetchFn ?? fetch; const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_GOOGLE_AUDIO_BASE_URL); + const allowPrivate = Boolean(params.baseUrl?.trim()); const model = resolveModel(params.model); const url = `${baseUrl}/models/${model}:generateContent`; @@ -52,7 +53,7 @@ export async function transcribeGeminiAudio( ], }; - const res = await fetchWithTimeout( + const { response: res, release } = await fetchWithTimeoutGuarded( url, { method: "POST", @@ -61,26 +62,31 @@ export async function transcribeGeminiAudio( }, params.timeoutMs, fetchFn, + allowPrivate ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined, ); - if (!res.ok) { - const detail = await readErrorResponse(res); - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); - } + try { + if (!res.ok) { + const detail = await readErrorResponse(res); + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); + } - const payload = (await res.json()) as { - candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; - }>; - }; - const parts = payload.candidates?.[0]?.content?.parts ?? []; - const text = parts - .map((part) => part?.text?.trim()) - .filter(Boolean) - .join("\n"); - if (!text) { - throw new Error("Audio transcription response missing text"); + const payload = (await res.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + const parts = payload.candidates?.[0]?.content?.parts ?? []; + const text = parts + .map((part) => part?.text?.trim()) + .filter(Boolean) + .join("\n"); + if (!text) { + throw new Error("Audio transcription response missing text"); + } + return { text, model }; + } finally { + await release(); } - return { text, model }; }
src/media-understanding/providers/google/video.ts+26 −20 modified@@ -1,6 +1,6 @@ import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js"; -import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; +import { fetchWithTimeoutGuarded, normalizeBaseUrl, readErrorResponse } from "../shared.js"; export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; @@ -24,6 +24,7 @@ export async function describeGeminiVideo( ): Promise<VideoDescriptionResult> { const fetchFn = params.fetchFn ?? fetch; const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_GOOGLE_VIDEO_BASE_URL); + const allowPrivate = Boolean(params.baseUrl?.trim()); const model = resolveModel(params.model); const url = `${baseUrl}/models/${model}:generateContent`; @@ -52,7 +53,7 @@ export async function describeGeminiVideo( ], }; - const res = await fetchWithTimeout( + const { response: res, release } = await fetchWithTimeoutGuarded( url, { method: "POST", @@ -61,26 +62,31 @@ export async function describeGeminiVideo( }, params.timeoutMs, fetchFn, + allowPrivate ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined, ); - if (!res.ok) { - const detail = await readErrorResponse(res); - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`Video description failed (HTTP ${res.status})${suffix}`); - } + try { + if (!res.ok) { + const detail = await readErrorResponse(res); + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`Video description failed (HTTP ${res.status})${suffix}`); + } - const payload = (await res.json()) as { - candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; - }>; - }; - const parts = payload.candidates?.[0]?.content?.parts ?? []; - const text = parts - .map((part) => part?.text?.trim()) - .filter(Boolean) - .join("\n"); - if (!text) { - throw new Error("Video description response missing text"); + const payload = (await res.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + const parts = payload.candidates?.[0]?.content?.parts ?? []; + const text = parts + .map((part) => part?.text?.trim()) + .filter(Boolean) + .join("\n"); + if (!text) { + throw new Error("Video description response missing text"); + } + return { text, model }; + } finally { + await release(); } - return { text, model }; }
src/media-understanding/providers/openai/audio.test.ts+28 −1 modified@@ -1,6 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../../infra/net/ssrf.js"; import { transcribeOpenAiCompatibleAudio } from "./audio.js"; +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const resolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; +const lookupMock = vi.fn(); +let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn> | null = null; +let resolvePinnedHostnameWithPolicySpy: ReturnType<typeof vi.spyOn> | null = null; + const resolveRequestUrl = (input: RequestInfo | URL) => { if (typeof input === "string") { return input; @@ -12,6 +19,26 @@ const resolveRequestUrl = (input: RequestInfo | URL) => { }; describe("transcribeOpenAiCompatibleAudio", () => { + beforeEach(() => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); + resolvePinnedHostnameWithPolicySpy = vi + .spyOn(ssrf, "resolvePinnedHostnameWithPolicy") + .mockImplementation((hostname, params) => + resolvePinnedHostnameWithPolicy(hostname, { ...params, lookupFn: lookupMock }), + ); + }); + + afterEach(() => { + lookupMock.mockReset(); + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameWithPolicySpy?.mockRestore(); + resolvePinnedHostnameSpy = null; + resolvePinnedHostnameWithPolicySpy = null; + }); + it("respects lowercase authorization header overrides", async () => { let seenAuth: string | null = null; const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
src/media-understanding/providers/openai/audio.ts+18 −12 modified@@ -1,6 +1,6 @@ import path from "node:path"; import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; -import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js"; +import { fetchWithTimeoutGuarded, normalizeBaseUrl, readErrorResponse } from "../shared.js"; export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; @@ -15,6 +15,7 @@ export async function transcribeOpenAiCompatibleAudio( ): Promise<AudioTranscriptionResult> { const fetchFn = params.fetchFn ?? fetch; const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_OPENAI_AUDIO_BASE_URL); + const allowPrivate = Boolean(params.baseUrl?.trim()); const url = `${baseUrl}/audio/transcriptions`; const model = resolveModel(params.model); @@ -38,7 +39,7 @@ export async function transcribeOpenAiCompatibleAudio( headers.set("authorization", `Bearer ${params.apiKey}`); } - const res = await fetchWithTimeout( + const { response: res, release } = await fetchWithTimeoutGuarded( url, { method: "POST", @@ -47,18 +48,23 @@ export async function transcribeOpenAiCompatibleAudio( }, params.timeoutMs, fetchFn, + allowPrivate ? { ssrfPolicy: { allowPrivateNetwork: true } } : undefined, ); - if (!res.ok) { - const detail = await readErrorResponse(res); - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); - } + try { + if (!res.ok) { + const detail = await readErrorResponse(res); + const suffix = detail ? `: ${detail}` : ""; + throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`); + } - const payload = (await res.json()) as { text?: string }; - const text = payload.text?.trim(); - if (!text) { - throw new Error("Audio transcription response missing text"); + const payload = (await res.json()) as { text?: string }; + const text = payload.text?.trim(); + if (!text) { + throw new Error("Audio transcription response missing text"); + } + return { text, model }; + } finally { + await release(); } - return { text, model }; }
src/media-understanding/providers/shared.ts+26 −0 modified@@ -1,3 +1,7 @@ +import type { GuardedFetchResult } from "../../infra/net/fetch-guard.js"; +import type { LookupFn, SsrFPolicy } from "../../infra/net/ssrf.js"; +import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; + const MAX_ERROR_CHARS = 300; export function normalizeBaseUrl(baseUrl: string | undefined, fallback: string): string { @@ -20,6 +24,28 @@ export async function fetchWithTimeout( } } +export async function fetchWithTimeoutGuarded( + url: string, + init: RequestInit, + timeoutMs: number, + fetchFn: typeof fetch, + options?: { + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + pinDns?: boolean; + }, +): Promise<GuardedFetchResult> { + return await fetchWithSsrFGuard({ + url, + fetchImpl: fetchFn, + init, + timeoutMs, + policy: options?.ssrfPolicy, + lookupFn: options?.lookupFn, + pinDns: options?.pinDns, + }); +} + export async function readErrorResponse(res: Response): Promise<string | undefined> { try { const text = await res.text();
src/slack/monitor/media.ts+1 −2 modified@@ -54,8 +54,7 @@ function resolveRequestUrl(input: RequestInfo | URL): string { if ("url" in input && typeof input.url === "string") { return input.url; } - - throw new Error(`Unable to resolve request URL from input: ${JSON.stringify(input, null, 2)}`); + throw new Error("Unsupported fetch input: expected string, URL, or Request"); } function createSlackMediaFetch(token: string): FetchLike {
src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts+16 −13 modified@@ -11,7 +11,9 @@ const sendChatActionSpy = vi.fn(); const cacheStickerSpy = vi.fn(); const getCachedStickerSpy = vi.fn(); const describeStickerImageSpy = vi.fn(); -const ssrfResolveSpy = vi.spyOn(ssrf, "resolvePinnedHostname"); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const lookupMock = vi.fn(); +let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn> | null = null; type ApiStub = { config: { use: (arg: unknown) => void }; @@ -28,15 +30,16 @@ const apiStub: ApiStub = { beforeEach(() => { vi.useRealTimers(); resetInboundDedupe(); - ssrfResolveSpy.mockImplementation(async (hostname) => { - const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); - const addresses = ["93.184.216.34"]; - return { - hostname: normalized, - addresses, - lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), - }; - }); + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); +}); + +afterEach(() => { + lookupMock.mockReset(); + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameSpy = null; }); vi.mock("grammy", () => ({ @@ -169,7 +172,7 @@ describe("telegram inbound media", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledWith( "https://api.telegram.org/file/bottok/photos/1.jpg", - expect.any(Object), + expect.objectContaining({ redirect: "manual" }), ); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; @@ -227,7 +230,7 @@ describe("telegram inbound media", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(proxyFetch).toHaveBeenCalledWith( "https://api.telegram.org/file/bottok/photos/2.jpg", - expect.any(Object), + expect.objectContaining({ redirect: "manual" }), ); globalFetchSpy.mockRestore(); @@ -501,7 +504,7 @@ describe("telegram stickers", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledWith( "https://api.telegram.org/file/bottok/stickers/sticker.webp", - expect.any(Object), + expect.objectContaining({ redirect: "manual" }), ); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0];
81c68f582d4afix: guard remote media fetches with SSRF checks
11 files changed · +423 −242
CHANGELOG.md+1 −0 modified@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). - Plugins: validate plugin/hook install paths and reject traversal-like names. - Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. - Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
src/agents/tools/web-fetch.ts+14 −86 modified@@ -1,13 +1,8 @@ -import type { Dispatcher } from "undici"; import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import type { AnyAgentTool } from "./common.js"; -import { - closeDispatcher, - createPinnedDispatcher, - resolvePinnedHostname, - SsrFBlockedError, -} from "../../infra/net/ssrf.js"; +import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; +import { SsrFBlockedError } from "../../infra/net/ssrf.js"; import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js"; import { stringEnum } from "../schema/typebox.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; @@ -184,79 +179,6 @@ function looksLikeHtml(value: string): boolean { return head.startsWith("<!doctype html") || head.startsWith("<html"); } -function isRedirectStatus(status: number): boolean { - return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; -} - -async function fetchWithRedirects(params: { - url: string; - maxRedirects: number; - timeoutSeconds: number; - userAgent: string; -}): Promise<{ response: Response; finalUrl: string; dispatcher: Dispatcher }> { - const signal = withTimeout(undefined, params.timeoutSeconds * 1000); - const visited = new Set<string>(); - let currentUrl = params.url; - let redirectCount = 0; - - while (true) { - let parsedUrl: URL; - try { - parsedUrl = new URL(currentUrl); - } catch { - throw new Error("Invalid URL: must be http or https"); - } - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - throw new Error("Invalid URL: must be http or https"); - } - - const pinned = await resolvePinnedHostname(parsedUrl.hostname); - const dispatcher = createPinnedDispatcher(pinned); - let res: Response; - try { - res = await fetch(parsedUrl.toString(), { - method: "GET", - headers: { - Accept: "*/*", - "User-Agent": params.userAgent, - "Accept-Language": "en-US,en;q=0.9", - }, - signal, - redirect: "manual", - dispatcher, - } as RequestInit); - } catch (err) { - await closeDispatcher(dispatcher); - throw err; - } - - if (isRedirectStatus(res.status)) { - const location = res.headers.get("location"); - if (!location) { - await closeDispatcher(dispatcher); - throw new Error(`Redirect missing location header (${res.status})`); - } - redirectCount += 1; - if (redirectCount > params.maxRedirects) { - await closeDispatcher(dispatcher); - throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); - } - const nextUrl = new URL(location, parsedUrl).toString(); - if (visited.has(nextUrl)) { - await closeDispatcher(dispatcher); - throw new Error("Redirect loop detected"); - } - visited.add(nextUrl); - void res.body?.cancel(); - await closeDispatcher(dispatcher); - currentUrl = nextUrl; - continue; - } - - return { response: res, finalUrl: currentUrl, dispatcher }; - } -} - function formatWebFetchErrorDetail(params: { detail: string; contentType?: string | null; @@ -465,18 +387,22 @@ async function runWebFetch(params: { const start = Date.now(); let res: Response; - let dispatcher: Dispatcher | null = null; + let release: (() => Promise<void>) | null = null; let finalUrl = params.url; try { - const result = await fetchWithRedirects({ + const result = await fetchWithSsrFGuard({ url: params.url, maxRedirects: params.maxRedirects, - timeoutSeconds: params.timeoutSeconds, - userAgent: params.userAgent, + timeoutMs: params.timeoutSeconds * 1000, + headers: { + Accept: "*/*", + "User-Agent": params.userAgent, + "Accept-Language": "en-US,en;q=0.9", + }, }); res = result.response; finalUrl = result.finalUrl; - dispatcher = result.dispatcher; + release = result.release; } catch (error) { if (error instanceof SsrFBlockedError) { throw error; @@ -630,7 +556,9 @@ async function runWebFetch(params: { writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); return payload; } finally { - await closeDispatcher(dispatcher); + if (release) { + await release(); + } } }
src/infra/net/fetch-guard.ts+170 −0 added@@ -0,0 +1,170 @@ +import type { Dispatcher } from "undici"; +import { + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostname, + resolvePinnedHostnameWithPolicy, + type LookupFn, + type SsrFPolicy, +} from "./ssrf.js"; + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; + +export type GuardedFetchOptions = { + url: string; + fetchImpl?: FetchLike; + method?: string; + headers?: HeadersInit; + maxRedirects?: number; + timeoutMs?: number; + signal?: AbortSignal; + policy?: SsrFPolicy; + lookupFn?: LookupFn; +}; + +export type GuardedFetchResult = { + response: Response; + finalUrl: string; + release: () => Promise<void>; +}; + +const DEFAULT_MAX_REDIRECTS = 3; + +function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { + signal?: AbortSignal; + cleanup: () => void; +} { + const { timeoutMs, signal } = params; + if (!timeoutMs && !signal) { + return { signal: undefined, cleanup: () => {} }; + } + + if (!timeoutMs) { + return { signal, cleanup: () => {} }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + const cleanup = () => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener("abort", onAbort); + } + }; + + return { signal: controller.signal, cleanup }; +} + +export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<GuardedFetchResult> { + const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; + if (!fetcher) { + throw new Error("fetch is not available"); + } + + const maxRedirects = + typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects) + ? Math.max(0, Math.floor(params.maxRedirects)) + : DEFAULT_MAX_REDIRECTS; + + const { signal, cleanup } = buildAbortSignal({ + timeoutMs: params.timeoutMs, + signal: params.signal, + }); + + let released = false; + const release = async (dispatcher?: Dispatcher | null) => { + if (released) { + return; + } + released = true; + cleanup(); + await closeDispatcher(dispatcher ?? undefined); + }; + + const visited = new Set<string>(); + let currentUrl = params.url; + let redirectCount = 0; + + while (true) { + let parsedUrl: URL; + try { + parsedUrl = new URL(currentUrl); + } catch { + await release(); + throw new Error("Invalid URL: must be http or https"); + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + await release(); + throw new Error("Invalid URL: must be http or https"); + } + + let dispatcher: Dispatcher | null = null; + try { + const usePolicy = Boolean( + params.policy?.allowPrivateNetwork || params.policy?.allowedHostnames?.length, + ); + const pinned = usePolicy + ? await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, { + lookupFn: params.lookupFn, + policy: params.policy, + }) + : await resolvePinnedHostname(parsedUrl.hostname, params.lookupFn); + dispatcher = createPinnedDispatcher(pinned); + + const init: RequestInit & { dispatcher?: Dispatcher } = { + redirect: "manual", + dispatcher, + ...(params.method ? { method: params.method } : {}), + ...(params.headers ? { headers: params.headers } : {}), + ...(signal ? { signal } : {}), + }; + + const response = await fetcher(parsedUrl.toString(), init); + + if (isRedirectStatus(response.status)) { + const location = response.headers.get("location"); + if (!location) { + await release(dispatcher); + throw new Error(`Redirect missing location header (${response.status})`); + } + redirectCount += 1; + if (redirectCount > maxRedirects) { + await release(dispatcher); + throw new Error(`Too many redirects (limit: ${maxRedirects})`); + } + const nextUrl = new URL(location, parsedUrl).toString(); + if (visited.has(nextUrl)) { + await release(dispatcher); + throw new Error("Redirect loop detected"); + } + visited.add(nextUrl); + void response.body?.cancel(); + await closeDispatcher(dispatcher); + currentUrl = nextUrl; + continue; + } + + return { + response, + finalUrl: currentUrl, + release: async () => release(dispatcher), + }; + } catch (err) { + await release(dispatcher); + throw err; + } + } +}
src/infra/net/ssrf.ts+39 −11 modified@@ -15,7 +15,12 @@ export class SsrFBlockedError extends Error { } } -type LookupFn = typeof dnsLookup; +export type LookupFn = typeof dnsLookup; + +export type SsrFPolicy = { + allowPrivateNetwork?: boolean; + allowedHostnames?: string[]; +}; const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"]; const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]); @@ -28,6 +33,13 @@ function normalizeHostname(hostname: string): string { return normalized; } +function normalizeHostnameSet(values?: string[]): Set<string> { + if (!values || values.length === 0) { + return new Set<string>(); + } + return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); +} + function parseIpv4(address: string): number[] | null { const parts = address.split("."); if (parts.length !== 4) { @@ -206,31 +218,40 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; -export async function resolvePinnedHostname( +export async function resolvePinnedHostnameWithPolicy( hostname: string, - lookupFn: LookupFn = dnsLookup, + params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {}, ): Promise<PinnedHostname> { const normalized = normalizeHostname(hostname); if (!normalized) { throw new Error("Invalid hostname"); } - if (isBlockedHostname(normalized)) { - throw new SsrFBlockedError(`Blocked hostname: ${hostname}`); - } + const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork); + const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); + const isExplicitAllowed = allowedHostnames.has(normalized); + + if (!allowPrivateNetwork && !isExplicitAllowed) { + if (isBlockedHostname(normalized)) { + throw new SsrFBlockedError(`Blocked hostname: ${hostname}`); + } - if (isPrivateIpAddress(normalized)) { - throw new SsrFBlockedError("Blocked: private/internal IP address"); + if (isPrivateIpAddress(normalized)) { + throw new SsrFBlockedError("Blocked: private/internal IP address"); + } } + const lookupFn = params.lookupFn ?? dnsLookup; const results = await lookupFn(normalized, { all: true }); if (results.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); } - for (const entry of results) { - if (isPrivateIpAddress(entry.address)) { - throw new SsrFBlockedError("Blocked: resolves to private/internal IP address"); + if (!allowPrivateNetwork && !isExplicitAllowed) { + for (const entry of results) { + if (isPrivateIpAddress(entry.address)) { + throw new SsrFBlockedError("Blocked: resolves to private/internal IP address"); + } } } @@ -246,6 +267,13 @@ export async function resolvePinnedHostname( }; } +export async function resolvePinnedHostname( + hostname: string, + lookupFn: LookupFn = dnsLookup, +): Promise<PinnedHostname> { + return await resolvePinnedHostnameWithPolicy(hostname, { lookupFn }); +} + export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher { return new Agent({ connect: {
src/media/fetch.test.ts+17 −1 modified@@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { fetchRemoteMedia } from "./fetch.js"; function makeStream(chunks: Uint8Array[]) { @@ -14,6 +14,7 @@ function makeStream(chunks: Uint8Array[]) { describe("fetchRemoteMedia", () => { it("rejects when content-length exceeds maxBytes", async () => { + const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), { status: 200, @@ -25,11 +26,13 @@ describe("fetchRemoteMedia", () => { url: "https://example.com/file.bin", fetchImpl, maxBytes: 4, + lookupFn, }), ).rejects.toThrow("exceeds maxBytes"); }); it("rejects when streamed payload exceeds maxBytes", async () => { + const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), { status: 200, @@ -40,7 +43,20 @@ describe("fetchRemoteMedia", () => { url: "https://example.com/file.bin", fetchImpl, maxBytes: 4, + lookupFn, }), ).rejects.toThrow("exceeds maxBytes"); }); + + it("blocks private IP literals before fetching", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchRemoteMedia({ + url: "http://127.0.0.1/secret.jpg", + fetchImpl, + maxBytes: 1024, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); });
src/media/fetch.ts+80 −62 modified@@ -1,4 +1,6 @@ import path from "node:path"; +import type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { detectMime, extensionForMime } from "./mime.js"; type FetchMediaResult = { @@ -26,6 +28,9 @@ type FetchMediaOptions = { fetchImpl?: FetchLike; filePathHint?: string; maxBytes?: number; + maxRedirects?: number; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; }; function stripQuotes(value: string): string { @@ -73,83 +78,96 @@ async function readErrorBodySnippet(res: Response, maxChars = 200): Promise<stri } export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> { - const { url, fetchImpl, filePathHint, maxBytes } = options; - const fetcher: FetchLike | undefined = fetchImpl ?? globalThis.fetch; - if (!fetcher) { - throw new Error("fetch is not available"); - } + const { url, fetchImpl, filePathHint, maxBytes, maxRedirects, ssrfPolicy, lookupFn } = options; let res: Response; + let finalUrl = url; + let release: (() => Promise<void>) | null = null; try { - res = await fetcher(url); + const result = await fetchWithSsrFGuard({ + url, + fetchImpl, + maxRedirects, + policy: ssrfPolicy, + lookupFn, + }); + res = result.response; + finalUrl = result.finalUrl; + release = result.release; } catch (err) { throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`); } - if (!res.ok) { - const statusText = res.statusText ? ` ${res.statusText}` : ""; - const redirected = res.url && res.url !== url ? ` (redirected to ${res.url})` : ""; - let detail = `HTTP ${res.status}${statusText}`; - if (!res.body) { - detail = `HTTP ${res.status}${statusText}; empty response body`; - } else { - const snippet = await readErrorBodySnippet(res); - if (snippet) { - detail += `; body: ${snippet}`; + try { + if (!res.ok) { + const statusText = res.statusText ? ` ${res.statusText}` : ""; + const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : ""; + let detail = `HTTP ${res.status}${statusText}`; + if (!res.body) { + detail = `HTTP ${res.status}${statusText}; empty response body`; + } else { + const snippet = await readErrorBodySnippet(res); + if (snippet) { + detail += `; body: ${snippet}`; + } } - } - throw new MediaFetchError( - "http_error", - `Failed to fetch media from ${url}${redirected}: ${detail}`, - ); - } - - const contentLength = res.headers.get("content-length"); - if (maxBytes && contentLength) { - const length = Number(contentLength); - if (Number.isFinite(length) && length > maxBytes) { throw new MediaFetchError( - "max_bytes", - `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`, + "http_error", + `Failed to fetch media from ${url}${redirected}: ${detail}`, ); } - } - const buffer = maxBytes - ? await readResponseWithLimit(res, maxBytes) - : Buffer.from(await res.arrayBuffer()); - let fileNameFromUrl: string | undefined; - try { - const parsed = new URL(url); - const base = path.basename(parsed.pathname); - fileNameFromUrl = base || undefined; - } catch { - // ignore parse errors; leave undefined - } + const contentLength = res.headers.get("content-length"); + if (maxBytes && contentLength) { + const length = Number(contentLength); + if (Number.isFinite(length) && length > maxBytes) { + throw new MediaFetchError( + "max_bytes", + `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`, + ); + } + } - const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition")); - let fileName = - headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : undefined); - - const filePathForMime = - headerFileName && path.extname(headerFileName) ? headerFileName : (filePathHint ?? url); - const contentType = await detectMime({ - buffer, - headerMime: res.headers.get("content-type"), - filePath: filePathForMime, - }); - if (fileName && !path.extname(fileName) && contentType) { - const ext = extensionForMime(contentType); - if (ext) { - fileName = `${fileName}${ext}`; + const buffer = maxBytes + ? await readResponseWithLimit(res, maxBytes) + : Buffer.from(await res.arrayBuffer()); + let fileNameFromUrl: string | undefined; + try { + const parsed = new URL(finalUrl); + const base = path.basename(parsed.pathname); + fileNameFromUrl = base || undefined; + } catch { + // ignore parse errors; leave undefined } - } - return { - buffer, - contentType: contentType ?? undefined, - fileName, - }; + const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition")); + let fileName = + headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : undefined); + + const filePathForMime = + headerFileName && path.extname(headerFileName) ? headerFileName : (filePathHint ?? finalUrl); + const contentType = await detectMime({ + buffer, + headerMime: res.headers.get("content-type"), + filePath: filePathForMime, + }); + if (fileName && !path.extname(fileName) && contentType) { + const ext = extensionForMime(contentType); + if (ext) { + fileName = `${fileName}${ext}`; + } + } + + return { + buffer, + contentType: contentType ?? undefined, + fileName, + }; + } finally { + if (release) { + await release(); + } + } } async function readResponseWithLimit(res: Response, maxBytes: number): Promise<Buffer> {
src/media/input-files.ts+29 −71 modified@@ -1,9 +1,4 @@ -import type { Dispatcher } from "undici"; -import { - closeDispatcher, - createPinnedDispatcher, - resolvePinnedHostname, -} from "../infra/net/ssrf.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { logWarn } from "../logger.js"; type CanvasModule = typeof import("@napi-rs/canvas"); @@ -112,10 +107,6 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4; export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000; export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200; -function isRedirectStatus(status: number): boolean { - return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; -} - export function normalizeMimeType(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -151,72 +142,39 @@ export async function fetchWithGuard(params: { timeoutMs: number; maxRedirects: number; }): Promise<InputFetchResult> { - let currentUrl = params.url; - let redirectCount = 0; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + maxRedirects: params.maxRedirects, + timeoutMs: params.timeoutMs, + headers: { "User-Agent": "OpenClaw-Gateway/1.0" }, + }); try { - while (true) { - const parsedUrl = new URL(currentUrl); - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`); - } - const pinned = await resolvePinnedHostname(parsedUrl.hostname); - const dispatcher = createPinnedDispatcher(pinned); - - try { - const response = await fetch(parsedUrl, { - signal: controller.signal, - headers: { "User-Agent": "OpenClaw-Gateway/1.0" }, - redirect: "manual", - dispatcher, - } as RequestInit & { dispatcher: Dispatcher }); - - if (isRedirectStatus(response.status)) { - const location = response.headers.get("location"); - if (!location) { - throw new Error(`Redirect missing location header (${response.status})`); - } - redirectCount += 1; - if (redirectCount > params.maxRedirects) { - throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); - } - void response.body?.cancel(); - currentUrl = new URL(location, parsedUrl).toString(); - continue; - } - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const size = parseInt(contentLength, 10); - if (size > params.maxBytes) { - throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); - } - } - - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.byteLength > params.maxBytes) { - throw new Error( - `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, - ); - } - - const contentType = response.headers.get("content-type") || undefined; - const parsed = parseContentType(contentType); - const mimeType = parsed.mimeType ?? "application/octet-stream"; - return { buffer, mimeType, contentType }; - } finally { - await closeDispatcher(dispatcher); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const size = parseInt(contentLength, 10); + if (size > params.maxBytes) { + throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); } } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > params.maxBytes) { + throw new Error( + `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, + ); + } + + const contentType = response.headers.get("content-type") || undefined; + const parsed = parseContentType(contentType); + const mimeType = parsed.mimeType ?? "application/octet-stream"; + return { buffer, mimeType, contentType }; } finally { - clearTimeout(timeoutId); + await release(); } }
src/slack/monitor/media.test.ts+11 −0 modified@@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; // Store original fetch const originalFetch = globalThis.fetch; @@ -171,11 +172,21 @@ describe("resolveSlackMedia", () => { beforeEach(() => { mockFetch = vi.fn(); globalThis.fetch = mockFetch as typeof fetch; + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); }); afterEach(() => { globalThis.fetch = originalFetch; vi.resetModules(); + vi.restoreAllMocks(); }); it("prefers url_private_download over url_private", async () => {
src/slack/monitor/media.ts+35 −7 modified@@ -44,6 +44,38 @@ function assertSlackFileUrl(rawUrl: string): URL { return parsed; } +function resolveRequestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if ("url" in input && typeof input.url === "string") { + return input.url; + } + return String(input); +} + +function createSlackMediaFetch(token: string): FetchLike { + let includeAuth = true; + return async (input, init) => { + const url = resolveRequestUrl(input); + const { headers: initHeaders, redirect: _redirect, ...rest } = init ?? {}; + const headers = new Headers(initHeaders); + + if (includeAuth) { + includeAuth = false; + const parsed = assertSlackFileUrl(url); + headers.set("Authorization", `Bearer ${token}`); + return fetch(parsed.href, { ...rest, headers, redirect: "manual" }); + } + + headers.delete("Authorization"); + return fetch(url, { ...rest, headers, redirect: "manual" }); + }; +} + /** * Fetches a URL with Authorization header, handling cross-origin redirects. * Node.js fetch strips Authorization headers on cross-origin redirects for security. @@ -100,13 +132,9 @@ export async function resolveSlackMedia(params: { } try { // Note: fetchRemoteMedia calls fetchImpl(url) with the URL string today and - // handles size limits internally. We ignore init options because - // fetchWithSlackAuth handles redirect/auth behavior specially. - const fetchImpl: FetchLike = (input) => { - const inputUrl = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - return fetchWithSlackAuth(inputUrl, params.token); - }; + // handles size limits internally. Provide a fetcher that uses auth once, then lets + // the redirect chain continue without credentials. + const fetchImpl = createSlackMediaFetch(params.token); const fetched = await fetchRemoteMedia({ url, fetchImpl,
src/web/media.test.ts+15 −1 modified@@ -2,7 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import sharp from "sharp"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../infra/net/ssrf.js"; import { optimizeImageToPng } from "../media/image-ops.js"; import { loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } from "./media.js"; @@ -31,9 +32,22 @@ function buildDeterministicBytes(length: number): Buffer { afterEach(async () => { await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true }))); tmpFiles.length = 0; + vi.restoreAllMocks(); }); describe("web media loading", () => { + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); + }); + it("compresses large local images under the provided cap", async () => { const buffer = await sharp({ create: {
src/web/media.ts+12 −3 modified@@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; import { fetchRemoteMedia } from "../media/fetch.js"; @@ -23,6 +24,7 @@ export type WebMediaResult = { type WebMediaOptions = { maxBytes?: number; optimizeImages?: boolean; + ssrfPolicy?: SsrFPolicy; }; const HEIC_MIME_RE = /^image\/hei[cf]$/i; @@ -122,7 +124,7 @@ async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, ): Promise<WebMediaResult> { - const { maxBytes, optimizeImages = true } = options; + const { maxBytes, optimizeImages = true, ssrfPolicy } = options; // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.) if (mediaUrl.startsWith("file://")) { try { @@ -209,7 +211,7 @@ async function loadWebMediaInternal( : optimizeImages ? Math.max(maxBytes, defaultFetchCap) : maxBytes; - const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap }); + const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap, ssrfPolicy }); const { buffer, contentType, fileName } = fetched; const kind = mediaKindFromMime(contentType); return await clampAndFinalize({ buffer, contentType, kind, fileName }); @@ -239,20 +241,27 @@ async function loadWebMediaInternal( }); } -export async function loadWebMedia(mediaUrl: string, maxBytes?: number): Promise<WebMediaResult> { +export async function loadWebMedia( + mediaUrl: string, + maxBytes?: number, + options?: { ssrfPolicy?: SsrFPolicy }, +): Promise<WebMediaResult> { return await loadWebMediaInternal(mediaUrl, { maxBytes, optimizeImages: true, + ssrfPolicy: options?.ssrfPolicy, }); } export async function loadWebMediaRaw( mediaUrl: string, maxBytes?: number, + options?: { ssrfPolicy?: SsrFPolicy }, ): Promise<WebMediaResult> { return await loadWebMediaInternal(mediaUrl, { maxBytes, optimizeImages: false, + ssrfPolicy: options?.ssrfPolicy, }); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/openclaw/openclaw/commit/81c68f582d4a9a20d9cca9f367d2da9edc5a65aeghsapatchWEB
- github.com/openclaw/openclaw/commit/9bd64c8a1f91dda602afc1d5246a2ff2be164647ghsapatchWEB
- github.com/advisories/GHSA-wfp2-v9c7-fh79ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-wfp2-v9c7-fh79ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28467ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-ssrf-via-attachment-media-url-hydrationghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.2ghsaWEB
News mentions
0No linked articles in our index yet.