fix(media): apply web fetch ssrf policy to media

This commit is contained in:
Neerav Makwana
2026-04-24 21:08:34 -04:00
committed by Peter Steinberger
parent a5db42862d
commit 86556fcd47
9 changed files with 101 additions and 5 deletions

View File

@@ -702,6 +702,34 @@ describe("createImageGenerateTool", () => {
);
});
it("passes web_fetch SSRF policy to remote reference images", async () => {
stubImageGenerationProviders();
stubEditedImageFlow({ width: 1024, height: 1024 });
const tool = requireImageGenerateTool(
createImageGenerateTool({
config: {
agents: {
defaults: { imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" } },
},
tools: { web: { fetch: { ssrfPolicy: { allowRfc2544BenchmarkRange: true } } } },
},
workspaceDir: process.cwd(),
}),
);
await tool.execute("call-edit-rfc2544", {
prompt: "Use this reference.",
image: "http://198.18.0.153/reference.png",
});
expect(webMedia.loadWebMedia).toHaveBeenCalledWith(
"http://198.18.0.153/reference.png",
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("ignores non-finite mediaMaxMb when loading reference images", async () => {
stubImageGenerationProviders();
stubEditedImageFlow({ width: 3200, height: 1800 });

View File

@@ -18,6 +18,7 @@ import type {
ImageGenerationResolution,
ImageGenerationSourceImage,
} from "../../image-generation/types.js";
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js";
import { getImageMetadata } from "../../media/image-ops.js";
import {
@@ -415,6 +416,7 @@ async function loadReferenceImages(params: {
maxBytes?: number;
workspaceDir?: string;
sandboxConfig: { root: string; bridge: SandboxFsBridge; workspaceOnly: boolean } | null;
ssrfPolicy?: SsrFPolicy;
}): Promise<
Array<{
sourceImage: ImageGenerationSourceImage;
@@ -491,6 +493,7 @@ async function loadReferenceImages(params: {
: await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes: params.maxBytes,
localRoots,
ssrfPolicy: params.ssrfPolicy,
});
if (media.kind !== "image") {
throw new ToolInputError(`Unsupported media type: ${media.kind}`);
@@ -549,6 +552,7 @@ export function createImageGenerateTool(options?: {
}
const effectiveCfg =
applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg;
const remoteMediaSsrfPolicy = effectiveCfg.tools?.web?.fetch?.ssrfPolicy;
const sandboxConfig =
options?.sandbox && options.sandbox.root.trim()
? {
@@ -646,6 +650,7 @@ export function createImageGenerateTool(options?: {
maxBytes: configuredMediaMaxBytes,
workspaceDir: options?.workspaceDir,
sandboxConfig,
ssrfPolicy: remoteMediaSsrfPolicy,
});
const inputImages = loadedReferenceImages.map((entry) => entry.sourceImage);
const modeCaps =

View File

@@ -1172,6 +1172,33 @@ describe("image tool implicit imageModel config", () => {
});
});
it("passes web_fetch SSRF policy to remote image references", async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith("http://198.18.0.153/")) {
return new Response(Buffer.from(ONE_PIXEL_PNG_B64, "base64"), {
headers: { "content-type": "image/png" },
});
}
return new Response(
JSON.stringify({ content: "ok", base_resp: { status_code: 0, status_msg: "" } }),
);
});
global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
...createMinimaxImageConfig(),
tools: { web: { fetch: { ssrfPolicy: { allowRfc2544BenchmarkRange: true } } } },
};
const tool = createRequiredImageTool({ config: cfg, agentDir });
await expectImageToolExecOk(tool, "http://198.18.0.153/reference.png");
expect(fetch).toHaveBeenCalledWith("http://198.18.0.153/reference.png", expect.any(Object));
});
});
it("sandboxes image paths like the read tool", async () => {
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");

View File

@@ -307,6 +307,7 @@ export function createImageTool(options?: {
if (!imageModelConfig) {
return null;
}
const remoteMediaSsrfPolicy = options?.config?.tools?.web?.fetch?.ssrfPolicy;
// If model has native vision, images in the prompt are auto-injected
// so this tool is only needed when image wasn't provided in the prompt
@@ -491,6 +492,7 @@ export function createImageTool(options?: {
: await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes,
localRoots: mediaLocalRoots,
ssrfPolicy: remoteMediaSsrfPolicy,
});
if (media.kind !== "image") {
throw new Error(`Unsupported media type: ${media.kind}`);

View File

@@ -495,6 +495,7 @@ export async function applyMediaUnderstanding(params: {
const providerRegistry = buildProviderRegistry(params.providers, cfg);
const cache = createMediaAttachmentCache(attachments, {
localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx }),
ssrfPolicy: cfg.tools?.web?.fetch?.ssrfPolicy,
});
try {

View File

@@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { isAbortError } from "../infra/unhandled-rejections.js";
import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
import { isInboundPathAllowed, mergeInboundPathRoots } from "../media/inbound-path-policy.js";
@@ -51,6 +52,7 @@ function getDefaultLocalPathRoots(): readonly string[] {
export type MediaAttachmentCacheOptions = {
localPathRoots?: readonly string[];
includeDefaultLocalPathRoots?: boolean;
ssrfPolicy?: SsrFPolicy;
};
function resolveRequestUrl(input: RequestInfo | URL): string {
@@ -67,10 +69,12 @@ export class MediaAttachmentCache {
private readonly entries = new Map<number, AttachmentCacheEntry>();
private readonly attachments: MediaAttachment[];
private readonly localPathRoots: readonly string[];
private readonly ssrfPolicy: SsrFPolicy | undefined;
private canonicalLocalPathRoots?: Promise<readonly string[]>;
constructor(attachments: MediaAttachment[], options?: MediaAttachmentCacheOptions) {
this.attachments = attachments;
this.ssrfPolicy = options?.ssrfPolicy;
this.localPathRoots =
options?.includeDefaultLocalPathRoots === false
? mergeInboundPathRoots(options.localPathRoots)
@@ -155,7 +159,12 @@ export class MediaAttachmentCache {
try {
const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) =>
fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, globalThis.fetch);
const fetched = await fetchRemoteMedia({ url, fetchImpl, maxBytes: params.maxBytes });
const fetched = await fetchRemoteMedia({
url,
fetchImpl,
maxBytes: params.maxBytes,
ssrfPolicy: this.ssrfPolicy,
});
entry.buffer = fetched.buffer;
entry.bufferMime =
entry.attachment.mime ??

View File

@@ -24,10 +24,10 @@ export async function runAudioTranscription(params: {
}
const providerRegistry = buildProviderRegistry(params.providers, params.cfg);
const cache = createMediaAttachmentCache(
attachments,
params.localPathRoots ? { localPathRoots: params.localPathRoots } : undefined,
);
const cache = createMediaAttachmentCache(attachments, {
...(params.localPathRoots ? { localPathRoots: params.localPathRoots } : {}),
ssrfPolicy: params.cfg.tools?.web?.fetch?.ssrfPolicy,
});
try {
const result = await runCapability({

View File

@@ -67,6 +67,29 @@ describe("media understanding attachments SSRF", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("allows RFC2544 benchmark-range URLs only when media fetch policy opts in", async () => {
const url = "http://198.18.0.153/file.jpg";
const deniedCache = new MediaAttachmentCache([{ index: 0, url }]);
await expect(
deniedCache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
).rejects.toThrow(/private|internal|blocked/i);
const fetchSpy = vi.fn().mockResolvedValue(
new Response("image", {
headers: { "content-type": "image/jpeg" },
}),
);
globalThis.fetch = withFetchPreconnect(fetchSpy);
const allowedCache = new MediaAttachmentCache([{ index: 0, url }], {
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
await expect(
allowedCache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
).resolves.toMatchObject({ mime: "image/jpeg" });
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("reads local attachments inside configured roots", async () => {
await withLocalAttachmentCache("openclaw-media-cache-allowed-", async ({ cache }) => {
const result = await cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 });

View File

@@ -68,6 +68,7 @@ export async function runMediaUnderstandingFile(
const providerRegistry = buildProviderRegistry(undefined, params.cfg);
const cache = createMediaAttachmentCache(attachments, {
localPathRoots: [path.dirname(params.filePath)],
ssrfPolicy: params.cfg.tools?.web?.fetch?.ssrfPolicy,
});
try {