Skip to content

Commit 0a09a8f

Browse files
committed
fix: propagate image generation SSRF policy (#79765) (thanks @hclsys)
1 parent b4d37fe commit 0a09a8f

21 files changed

Lines changed: 204 additions & 3 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai
157157
### Fixes
158158

159159
- 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.
160+
- 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.
160161
- 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.
161162
- 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.
162163
- 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.

extensions/google/image-generation-provider.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,49 @@ describe("Google image-generation provider", () => {
119119
});
120120
});
121121

122+
it("passes request SSRF policy to the provider HTTP helper", async () => {
123+
mockGoogleApiKeyAuth();
124+
const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
125+
response: new Response(
126+
JSON.stringify({
127+
candidates: [
128+
{
129+
content: {
130+
parts: [
131+
{
132+
inlineData: {
133+
mimeType: "image/png",
134+
data: Buffer.from("png-data").toString("base64"),
135+
},
136+
},
137+
],
138+
},
139+
},
140+
],
141+
}),
142+
{ status: 200, headers: { "Content-Type": "application/json" } },
143+
),
144+
finalUrl:
145+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent",
146+
release: async () => {},
147+
});
148+
149+
const provider = buildGoogleImageGenerationProvider();
150+
await provider.generateImage({
151+
provider: "google",
152+
model: "gemini-3.1-flash-image-preview",
153+
prompt: "draw a cat",
154+
cfg: {},
155+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
156+
});
157+
158+
expect(postJsonRequest).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
161+
}),
162+
);
163+
});
164+
122165
it("accepts OAuth JSON auth and inline_data responses", async () => {
123166
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
124167
apiKey: JSON.stringify({ token: "oauth-token" }),

extensions/google/image-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
177177
fetchFn: fetch,
178178
pinDns: false,
179179
allowPrivateNetwork,
180+
ssrfPolicy: req.ssrfPolicy,
180181
dispatcherPolicy,
181182
});
182183

extensions/minimax/image-generation-provider.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
2+
import * as providerHttp from "openclaw/plugin-sdk/provider-http";
23
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
34
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
import {
@@ -93,6 +94,36 @@ describe("minimax image-generation provider", () => {
9394
});
9495
});
9596

97+
it("passes request SSRF policy to the provider HTTP helper", async () => {
98+
mockMinimaxApiKey();
99+
const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({
100+
response: new Response(
101+
JSON.stringify({
102+
data: { image_base64: [Buffer.from("png-data").toString("base64")] },
103+
base_resp: { status_code: 0 },
104+
}),
105+
{ status: 200, headers: { "Content-Type": "application/json" } },
106+
),
107+
finalUrl: "https://api.minimax.io/v1/image_generation",
108+
release: async () => {},
109+
});
110+
111+
const provider = buildMinimaxImageGenerationProvider();
112+
await provider.generateImage({
113+
provider: "minimax",
114+
model: "image-01",
115+
prompt: "draw a cat",
116+
cfg: {},
117+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
118+
});
119+
120+
expect(postJsonRequest).toHaveBeenCalledWith(
121+
expect.objectContaining({
122+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
123+
}),
124+
);
125+
});
126+
96127
it("keeps the dedicated global image endpoint when text config uses the global API host", async () => {
97128
mockMinimaxApiKey();
98129
const fetchMock = mockSuccessfulMinimaxImageResponse();

extensions/minimax/image-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider
155155
timeoutMs: req.timeoutMs,
156156
fetchFn: fetch,
157157
allowPrivateNetwork,
158+
ssrfPolicy: req.ssrfPolicy,
158159
dispatcherPolicy,
159160
});
160161
try {

extensions/openai/image-generation-provider.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,25 @@ describe("openai image generation provider", () => {
364364
expect(result.images).toHaveLength(1);
365365
});
366366

367+
it("propagates request SSRF policy to JSON image requests", async () => {
368+
mockGeneratedPngResponse();
369+
370+
const provider = buildOpenAIImageGenerationProvider();
371+
await provider.generateImage({
372+
provider: "openai",
373+
model: "gpt-image-2",
374+
prompt: "test",
375+
cfg: {},
376+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
377+
});
378+
379+
expect(postJsonRequestMock).toHaveBeenCalledWith(
380+
expect.objectContaining({
381+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
382+
}),
383+
);
384+
});
385+
367386
it("forwards generation count and custom size overrides", async () => {
368387
mockGeneratedPngResponse();
369388

@@ -1068,6 +1087,7 @@ describe("openai image generation provider", () => {
10681087
},
10691088
},
10701089
authStore,
1090+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
10711091
});
10721092

10731093
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith({
@@ -1083,6 +1103,7 @@ describe("openai image generation provider", () => {
10831103
expect.objectContaining({
10841104
url: "http://127.0.0.1:44220/backend-api/codex/responses",
10851105
allowPrivateNetwork: true,
1106+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
10861107
}),
10871108
);
10881109
expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-image"));

extensions/openai/image-generation-provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ async function generateOpenAICodexImage(params: {
681681
timeoutMs,
682682
fetchFn: fetch,
683683
allowPrivateNetwork,
684+
ssrfPolicy: req.ssrfPolicy,
684685
dispatcherPolicy,
685686
});
686687
const { response, release } = requestResult;
@@ -842,6 +843,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
842843
timeoutMs,
843844
fetchFn: fetch,
844845
allowPrivateNetwork,
846+
ssrfPolicy: req.ssrfPolicy,
845847
dispatcherPolicy,
846848
});
847849
})()
@@ -864,6 +866,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
864866
timeoutMs,
865867
fetchFn: fetch,
866868
allowPrivateNetwork,
869+
ssrfPolicy: req.ssrfPolicy,
867870
dispatcherPolicy,
868871
});
869872
})();

extensions/openrouter/image-generation-provider.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ describe("openrouter image generation provider", () => {
106106
resolution: "2K",
107107
count: 2,
108108
timeoutMs: 12_345,
109+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
109110
cfg: {
110111
models: {
111112
providers: {
@@ -131,6 +132,7 @@ describe("openrouter image generation provider", () => {
131132
expect.objectContaining({
132133
url: "https://custom.openrouter.test/api/v1/chat/completions",
133134
timeoutMs: 12_345,
135+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
134136
body: expect.objectContaining({
135137
model: "google/gemini-3.1-flash-image-preview",
136138
modalities: ["image", "text"],

extensions/openrouter/image-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide
249249
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
250250
fetchFn: fetch,
251251
allowPrivateNetwork,
252+
ssrfPolicy: req.ssrfPolicy,
252253
dispatcherPolicy,
253254
});
254255

extensions/vydra/image-generation-provider.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,41 @@ describe("vydra image-generation provider", () => {
6666
});
6767
});
6868

69+
it("passes request SSRF policy to the image creation request", async () => {
70+
stubVydraApiKey();
71+
const fetchMock = stubFetch(
72+
jsonResponse({
73+
jobId: "job-123",
74+
status: "completed",
75+
imageUrl: "https://cdn.vydra.ai/generated/test.png",
76+
}),
77+
binaryResponse("png-data", "image/png"),
78+
);
79+
80+
const provider = buildVydraImageGenerationProvider();
81+
await provider.generateImage({
82+
provider: "vydra",
83+
model: "grok-imagine",
84+
prompt: "draw a cat",
85+
cfg: {
86+
models: {
87+
providers: {
88+
vydra: {
89+
baseUrl: "https://198.18.0.10/api/v1",
90+
},
91+
},
92+
},
93+
} as never,
94+
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
95+
});
96+
97+
expect(fetchMock).toHaveBeenNthCalledWith(
98+
1,
99+
"https://198.18.0.10/api/v1/models/grok-imagine",
100+
expect.objectContaining({ method: "POST" }),
101+
);
102+
});
103+
69104
it("polls jobs when the create response is not completed yet", async () => {
70105
stubVydraApiKey();
71106
const fetchMock = stubFetch(

0 commit comments

Comments
 (0)