mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 09:10:42 +00:00
fix: propagate image generation SSRF policy (#79765) (thanks @hclsys)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -177,6 +177,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
|
||||
fetchFn: fetch,
|
||||
pinDns: false,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -155,6 +155,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -249,6 +249,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
|
||||
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -67,6 +67,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -742,6 +742,7 @@ export function createImageGenerateTool(options?: {
|
||||
inputImages,
|
||||
timeoutMs,
|
||||
providerOptions,
|
||||
ssrfPolicy: remoteMediaSsrfPolicy,
|
||||
});
|
||||
const ignoredOverrides = result.ignoredOverrides ?? [];
|
||||
const displayProvider = sanitizeInlineDirectiveText(result.provider);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user