fix: propagate image generation SSRF policy (#79765) (thanks @hclsys)

This commit is contained in:
Peter Steinberger
2026-05-09 12:35:54 +01:00
parent b4d37feec6
commit 0a09a8f02f
21 changed files with 204 additions and 3 deletions

View File

@@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
- QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee.
- Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee.
- Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys.

View File

@@ -119,6 +119,49 @@ describe("Google image-generation provider", () => {
});
});
it("passes request SSRF policy to the provider HTTP helper", async () => {
mockGoogleApiKeyAuth();
const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
response: new Response(
JSON.stringify({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("png-data").toString("base64"),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl:
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent",
release: async () => {},
});
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(postJsonRequest).toHaveBeenCalledWith(
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("accepts OAuth JSON auth and inline_data responses", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: JSON.stringify({ token: "oauth-token" }),

View File

@@ -177,6 +177,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
fetchFn: fetch,
pinDns: false,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});

View File

@@ -1,4 +1,5 @@
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -93,6 +94,36 @@ describe("minimax image-generation provider", () => {
});
});
it("passes request SSRF policy to the provider HTTP helper", async () => {
mockMinimaxApiKey();
const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
response: new Response(
JSON.stringify({
data: { image_base64: [Buffer.from("png-data").toString("base64")] },
base_resp: { status_code: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
finalUrl: "https://api.minimax.io/v1/image_generation",
release: async () => {},
});
const provider = buildMinimaxImageGenerationProvider();
await provider.generateImage({
provider: "minimax",
model: "image-01",
prompt: "draw a cat",
cfg: {},
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(postJsonRequest).toHaveBeenCalledWith(
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("keeps the dedicated global image endpoint when text config uses the global API host", async () => {
mockMinimaxApiKey();
const fetchMock = mockSuccessfulMinimaxImageResponse();

View File

@@ -155,6 +155,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
timeoutMs: req.timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
try {

View File

@@ -364,6 +364,25 @@ describe("openai image generation provider", () => {
expect(result.images).toHaveLength(1);
});
it("propagates request SSRF policy to JSON image requests", async () => {
mockGeneratedPngResponse();
const provider = buildOpenAIImageGenerationProvider();
await provider.generateImage({
provider: "openai",
model: "gpt-image-2",
prompt: "test",
cfg: {},
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("forwards generation count and custom size overrides", async () => {
mockGeneratedPngResponse();
@@ -1068,6 +1087,7 @@ describe("openai image generation provider", () => {
},
},
authStore,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith({
@@ -1083,6 +1103,7 @@ describe("openai image generation provider", () => {
expect.objectContaining({
url: "http://127.0.0.1:44220/backend-api/codex/responses",
allowPrivateNetwork: true,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-image"));

View File

@@ -681,6 +681,7 @@ async function generateOpenAICodexImage(params: {
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
const { response, release } = requestResult;
@@ -842,6 +843,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
})()
@@ -864,6 +866,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
})();

View File

@@ -106,6 +106,7 @@ describe("openrouter image generation provider", () => {
resolution: "2K",
count: 2,
timeoutMs: 12_345,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
cfg: {
models: {
providers: {
@@ -131,6 +132,7 @@ describe("openrouter image generation provider", () => {
expect.objectContaining({
url: "https://custom.openrouter.test/api/v1/chat/completions",
timeoutMs: 12_345,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
body: expect.objectContaining({
model: "google/gemini-3.1-flash-image-preview",
modalities: ["image", "text"],

View File

@@ -249,6 +249,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});

View File

@@ -66,6 +66,41 @@ describe("vydra image-generation provider", () => {
});
});
it("passes request SSRF policy to the image creation request", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {
models: {
providers: {
vydra: {
baseUrl: "https://198.18.0.10/api/v1",
},
},
},
} as never,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://198.18.0.10/api/v1/models/grok-imagine",
expect.objectContaining({ method: "POST" }),
);
});
it("polls jobs when the create response is not completed yet", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(

View File

@@ -67,6 +67,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});

View File

@@ -971,7 +971,7 @@ describe("createImageGenerateTool", () => {
it("passes web_fetch SSRF policy to remote reference images", async () => {
stubImageGenerationProviders();
stubEditedImageFlow({ width: 1024, height: 1024 });
const generateImage = stubEditedImageFlow({ width: 1024, height: 1024 });
const defaultTool = requireImageGenerateTool(
createImageGenerateTool({
config: {
@@ -1015,6 +1015,11 @@ describe("createImageGenerateTool", () => {
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
expect(generateImage).toHaveBeenLastCalledWith(
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("ignores non-finite mediaMaxMb when loading reference images", async () => {

View File

@@ -742,6 +742,7 @@ export function createImageGenerateTool(options?: {
inputImages,
timeoutMs,
providerOptions,
ssrfPolicy: remoteMediaSsrfPolicy,
});
const ignoredOverrides = result.ignoredOverrides ?? [];
const displayProvider = sanitizeInlineDirectiveText(result.provider);

View File

@@ -166,6 +166,7 @@ describe("OpenAI-compatible image provider helper", () => {
prompt: "draw a square",
count: 2,
size: "512x512",
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
cfg: {
models: {
providers: {
@@ -188,6 +189,7 @@ describe("OpenAI-compatible image provider helper", () => {
expect.objectContaining({
url: "https://sample.example/v1/images/generations",
allowPrivateNetwork: true,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
dispatcherPolicy: { request: { allowPrivateNetwork: true } },
body: {
model: "custom-image",

View File

@@ -234,6 +234,7 @@ export function createOpenAiCompatibleImageGenerationProvider(
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork: resolvedAllowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
})
: postJsonRequest({
@@ -247,6 +248,7 @@ export function createOpenAiCompatibleImageGenerationProvider(
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork: resolvedAllowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});

View File

@@ -1,6 +1,7 @@
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { FallbackAttempt } from "../agents/model-fallback.types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type {
GeneratedImageAsset,
ImageGenerationBackground,
@@ -32,6 +33,8 @@ export type GenerateImageParams = {
/** Optional per-request provider timeout in milliseconds. */
timeoutMs?: number;
providerOptions?: ImageGenerationProviderOptions;
/** SSRF policy to propagate into image-generation provider HTTP calls. */
ssrfPolicy?: SsrFPolicy;
};
export type GenerateImageRuntimeResult = {

View File

@@ -43,15 +43,17 @@ describe("image-generation runtime", () => {
const authStore = { version: 1, profiles: {} } as const;
let seenAuthStore: unknown;
let seenTimeoutMs: number | undefined;
let seenSsrfPolicy: unknown;
const provider: ImageGenerationProvider = {
id: "image-plugin",
capabilities: {
generate: {},
edit: { enabled: false },
},
async generateImage(req: { authStore?: unknown; timeoutMs?: number }) {
async generateImage(req: { authStore?: unknown; timeoutMs?: number; ssrfPolicy?: unknown }) {
seenAuthStore = req.authStore;
seenTimeoutMs = req.timeoutMs;
seenSsrfPolicy = req.ssrfPolicy;
return {
images: [
{
@@ -78,6 +80,7 @@ describe("image-generation runtime", () => {
agentDir: "/tmp/agent",
authStore,
timeoutMs: 12_345,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(result.provider).toBe("image-plugin");
@@ -85,6 +88,7 @@ describe("image-generation runtime", () => {
expect(result.attempts).toStrictEqual([]);
expect(seenAuthStore).toEqual(authStore);
expect(seenTimeoutMs).toBe(12_345);
expect(seenSsrfPolicy).toEqual({ allowRfc2544BenchmarkRange: true });
expect(result.images).toEqual([
{
buffer: Buffer.from("png-bytes"),

View File

@@ -118,6 +118,7 @@ export async function generateImage(
inputImages: params.inputImages,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
providerOptions: params.providerOptions,
ssrfPolicy: params.ssrfPolicy,
});
if (!Array.isArray(result.images) || result.images.length === 0) {
throw new Error("Image generation provider returned no images.");

View File

@@ -1,5 +1,6 @@
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type { MediaNormalizationEntry } from "../media-generation/normalization.types.js";
export type GeneratedImageAsset = {
@@ -75,6 +76,7 @@ export type ImageGenerationRequest = {
background?: ImageGenerationBackground;
inputImages?: ImageGenerationSourceImage[];
providerOptions?: ImageGenerationProviderOptions;
ssrfPolicy?: SsrFPolicy;
};
export type ImageGenerationResult = {

View File

@@ -365,6 +365,28 @@ describe("fetchWithTimeoutGuarded", () => {
});
});
it("merges full SSRF policy into JSON request guards", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(null, { status: 200 }),
finalUrl: "https://example.com",
release: async () => {},
});
await postJsonRequest({
url: "https://api.example.com/v1/test",
headers: new Headers(),
body: { ok: true },
fetchFn: fetch,
allowPrivateNetwork: true,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
expect(getFirstGuardedFetchCall().policy).toEqual({
allowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
});
});
it("forwards explicit pinDns overrides to JSON requests", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(null, { status: 200 }),

View File

@@ -319,15 +319,30 @@ export async function fetchWithTimeoutGuarded(
type GuardedPostRequestOptions = NonNullable<Parameters<typeof fetchWithTimeoutGuarded>[4]>;
function mergeGuardedPostSsrfPolicy(params: {
ssrfPolicy?: SsrFPolicy;
allowPrivateNetwork?: boolean;
}): SsrFPolicy | undefined {
if (!params.ssrfPolicy) {
return params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
}
if (!params.allowPrivateNetwork) {
return params.ssrfPolicy;
}
return { ...params.ssrfPolicy, allowPrivateNetwork: true };
}
function resolveGuardedPostRequestOptions(params: {
pinDns?: boolean;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
auditContext?: string;
mode?: GuardedFetchMode;
}): GuardedPostRequestOptions | undefined {
if (
!params.allowPrivateNetwork &&
!params.ssrfPolicy &&
!params.dispatcherPolicy &&
params.pinDns === undefined &&
!params.auditContext &&
@@ -335,8 +350,9 @@ function resolveGuardedPostRequestOptions(params: {
) {
return undefined;
}
const ssrfPolicy = mergeGuardedPostSsrfPolicy(params);
return {
...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(ssrfPolicy ? { ssrfPolicy } : {}),
...(params.pinDns !== undefined ? { pinDns: params.pinDns } : {}),
...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}),
...(params.auditContext ? { auditContext: params.auditContext } : {}),
@@ -352,6 +368,7 @@ export async function postTranscriptionRequest(params: {
fetchFn: typeof fetch;
pinDns?: boolean;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
auditContext?: string;
/**
@@ -382,6 +399,7 @@ export async function postJsonRequest(params: {
fetchFn: typeof fetch;
pinDns?: boolean;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
auditContext?: string;
/**
@@ -412,6 +430,7 @@ export async function postMultipartRequest(params: {
fetchFn: typeof fetch;
pinDns?: boolean;
allowPrivateNetwork?: boolean;
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
auditContext?: string;
/**