VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.22026.2.2

Affected products

1

Patches

2
9bd64c8a1f91

fix: expand SSRF guard coverage

https://github.com/openclaw/openclawPeter SteinbergerFeb 2, 2026via ghsa
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];
    
81c68f582d4a

fix: guard remote media fetches with SSRF checks

https://github.com/openclaw/openclawPeter SteinbergerFeb 2, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.