Skip to content

Commit 0ad2dbd

Browse files
authored
fix(providers): route image generation through shared transport (#59729)
* fix(providers): route image generation through shared transport * fix(providers): use normalized minimax image base url * fix(providers): fail closed on image private routes * fix(providers): bound shared HTTP fetches
1 parent d2ce3e9 commit 0ad2dbd

9 files changed

Lines changed: 500 additions & 161 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Docs: https://docs.openclaw.ai
4646
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
4747
- Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic `service_tier` handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.
4848
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
49+
- Image generation/providers: route OpenAI, MiniMax, and fal image requests through the shared provider HTTP transport path so custom base URLs, guarded private-network routing, and provider request defaults stay aligned with the rest of provider HTTP. Thanks @vincentkoc.
50+
- Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc.
4951
- Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.
5052
- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf.
5153
- Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.

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

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,14 @@ import {
1111
} from "./image-generation-provider.js";
1212

1313
function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) {
14-
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
15-
params.call,
16-
expect.objectContaining({
17-
url: params.url,
18-
init: expect.objectContaining({
19-
method: "POST",
20-
headers: expect.objectContaining({
21-
Authorization: "Key fal-test-key",
22-
"Content-Type": "application/json",
23-
}),
24-
}),
25-
auditContext: "fal-image-generate",
26-
}),
27-
);
28-
2914
const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0];
3015
expect(request).toBeTruthy();
16+
expect(request?.url).toBe(params.url);
17+
expect(request?.auditContext).toBe("fal-image-generate");
18+
expect(request?.init?.method).toBe("POST");
19+
const headers = new Headers(request?.init?.headers);
20+
expect(headers.get("authorization")).toBe("Key fal-test-key");
21+
expect(headers.get("content-type")).toBe("application/json");
3122
expect(JSON.parse(String(request?.init?.body))).toEqual(params.body);
3223
}
3324

@@ -361,17 +352,13 @@ describe("fal image-generation provider", () => {
361352
);
362353
});
363354

364-
it("allows trusted private relay hosts derived from configured baseUrl", async () => {
355+
it("does not auto-whitelist trusted private relay hosts from a configured baseUrl", async () => {
365356
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
366357
apiKey: "fal-test-key",
367358
source: "env",
368359
mode: "api-key",
369360
});
370361
_setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
371-
const relayPolicy = {
372-
allowPrivateNetwork: true,
373-
hostnameAllowlist: ["relay.internal", "*.relay.internal"],
374-
};
375362
fetchWithSsrFGuardMock
376363
.mockResolvedValueOnce({
377364
response: new Response(
@@ -415,15 +402,15 @@ describe("fal image-generation provider", () => {
415402
expect.objectContaining({
416403
url: "http://relay.internal:8080/fal-ai/flux/dev",
417404
auditContext: "fal-image-generate",
418-
policy: relayPolicy,
405+
policy: undefined,
419406
}),
420407
);
421408
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
422409
2,
423410
expect.objectContaining({
424411
url: "http://media.relay.internal/files/generated.png",
425412
auditContext: "fal-image-download",
426-
policy: relayPolicy,
413+
policy: undefined,
427414
}),
428415
);
429416
});

extensions/fal/image-generation-provider.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type {
33
ImageGenerationProvider,
44
} from "openclaw/plugin-sdk/image-generation";
55
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
6+
import {
7+
assertOkOrThrowHttpError,
8+
resolveProviderHttpRequestConfig,
9+
} from "openclaw/plugin-sdk/provider-http";
610
import {
711
buildHostnameAllowlistPolicyFromSuffixAllowlist,
812
fetchWithSsrFGuard,
@@ -81,40 +85,32 @@ function matchesTrustedHostSuffix(hostname: string, trustedSuffix: string): bool
8185
return normalizedHost === normalizedSuffix || normalizedHost.endsWith(`.${normalizedSuffix}`);
8286
}
8387

84-
function resolveFalNetworkPolicy(
85-
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
86-
): FalNetworkPolicy {
87-
const baseUrl = resolveFalBaseUrl(cfg);
88-
const explicitBaseUrl = cfg?.models?.providers?.fal?.baseUrl?.trim();
88+
function resolveFalNetworkPolicy(params: {
89+
baseUrl: string;
90+
allowPrivateNetwork: boolean;
91+
}): FalNetworkPolicy {
8992
let parsedBaseUrl: URL;
9093
try {
91-
parsedBaseUrl = new URL(baseUrl);
94+
parsedBaseUrl = new URL(params.baseUrl);
9295
} catch {
9396
return {};
9497
}
9598

9699
const hostSuffix = parsedBaseUrl.hostname.trim().toLowerCase();
97-
if (!hostSuffix) {
100+
if (!hostSuffix || !params.allowPrivateNetwork) {
98101
return {};
99102
}
100103

101104
const hostPolicy = buildHostnameAllowlistPolicyFromSuffixAllowlist([hostSuffix]);
102-
const privateNetworkPolicy = explicitBaseUrl
103-
? ssrfPolicyFromAllowPrivateNetwork(true)
104-
: undefined;
105+
const privateNetworkPolicy = ssrfPolicyFromAllowPrivateNetwork(true);
105106
const trustedHostPolicy = mergeSsrFPolicies(hostPolicy, privateNetworkPolicy);
106107
return {
107108
apiPolicy: trustedHostPolicy,
108-
trustedDownloadHostSuffix: explicitBaseUrl ? hostSuffix : undefined,
109-
trustedDownloadPolicy: explicitBaseUrl ? trustedHostPolicy : undefined,
109+
trustedDownloadHostSuffix: hostSuffix,
110+
trustedDownloadPolicy: trustedHostPolicy,
110111
};
111112
}
112113

113-
function resolveFalBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string {
114-
const direct = cfg?.models?.providers?.fal?.baseUrl?.trim();
115-
return (direct || DEFAULT_FAL_BASE_URL).replace(/\/+$/u, "");
116-
}
117-
118114
function ensureFalModelPath(model: string | undefined, hasInputImages: boolean): string {
119115
const trimmed = model?.trim() || DEFAULT_FAL_IMAGE_MODEL;
120116
if (!hasInputImages) {
@@ -341,7 +337,21 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
341337
hasInputImages,
342338
});
343339
const model = ensureFalModelPath(req.model, hasInputImages);
344-
const networkPolicy = resolveFalNetworkPolicy(req.cfg);
340+
const explicitBaseUrl = req.cfg?.models?.providers?.fal?.baseUrl?.trim();
341+
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
342+
resolveProviderHttpRequestConfig({
343+
baseUrl: explicitBaseUrl,
344+
defaultBaseUrl: DEFAULT_FAL_BASE_URL,
345+
allowPrivateNetwork: false,
346+
defaultHeaders: {
347+
Authorization: `Key ${auth.apiKey}`,
348+
"Content-Type": "application/json",
349+
},
350+
provider: "fal",
351+
capability: "image",
352+
transport: "http",
353+
});
354+
const networkPolicy = resolveFalNetworkPolicy({ baseUrl, allowPrivateNetwork });
345355
const requestBody: Record<string, unknown> = {
346356
prompt: req.prompt,
347357
num_images: req.count ?? 1,
@@ -358,27 +368,20 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
358368
}
359369
requestBody.image_url = toDataUri(input.buffer, input.mimeType);
360370
}
361-
362371
const { response, release } = await falFetchGuard({
363-
url: `${resolveFalBaseUrl(req.cfg)}/${model}`,
372+
url: `${baseUrl}/${model}`,
364373
init: {
365374
method: "POST",
366-
headers: {
367-
Authorization: `Key ${auth.apiKey}`,
368-
"Content-Type": "application/json",
369-
},
375+
headers,
370376
body: JSON.stringify(requestBody),
371377
},
378+
timeoutMs: req.timeoutMs,
372379
policy: networkPolicy.apiPolicy,
380+
dispatcherPolicy,
373381
auditContext: "fal-image-generate",
374382
});
375383
try {
376-
if (!response.ok) {
377-
const text = await response.text().catch(() => "");
378-
throw new Error(
379-
`fal image generation failed (${response.status}): ${text || response.statusText}`,
380-
);
381-
}
384+
await assertOkOrThrowHttpError(response, "fal image generation failed");
382385

383386
const payload = (await response.json()) as FalImageGenerationResponse;
384387
const images: GeneratedImageAsset[] = [];
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { buildMinimaxImageGenerationProvider } from "./image-generation-provider.js";
4+
5+
describe("minimax image-generation provider", () => {
6+
beforeEach(() => {
7+
vi.clearAllMocks();
8+
});
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
});
13+
14+
it("generates PNG buffers through the shared provider HTTP path", async () => {
15+
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
16+
apiKey: "minimax-test-key",
17+
source: "env",
18+
mode: "api-key",
19+
});
20+
const fetchMock = vi.fn().mockResolvedValue(
21+
new Response(
22+
JSON.stringify({
23+
data: {
24+
image_base64: [Buffer.from("png-data").toString("base64")],
25+
},
26+
base_resp: { status_code: 0 },
27+
}),
28+
{
29+
status: 200,
30+
headers: { "Content-Type": "application/json" },
31+
},
32+
),
33+
);
34+
vi.stubGlobal("fetch", fetchMock);
35+
36+
const provider = buildMinimaxImageGenerationProvider();
37+
const result = await provider.generateImage({
38+
provider: "minimax",
39+
model: "image-01",
40+
prompt: "draw a cat",
41+
cfg: {},
42+
});
43+
44+
expect(fetchMock).toHaveBeenCalledWith(
45+
"https://api.minimax.io/v1/image_generation",
46+
expect.objectContaining({
47+
method: "POST",
48+
body: JSON.stringify({
49+
model: "image-01",
50+
prompt: "draw a cat",
51+
response_format: "base64",
52+
n: 1,
53+
}),
54+
}),
55+
);
56+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
57+
const headers = new Headers(init.headers);
58+
expect(headers.get("authorization")).toBe("Bearer minimax-test-key");
59+
expect(headers.get("content-type")).toBe("application/json");
60+
expect(result).toEqual({
61+
images: [
62+
{
63+
buffer: Buffer.from("png-data"),
64+
mimeType: "image/png",
65+
fileName: "image-1.png",
66+
},
67+
],
68+
model: "image-01",
69+
});
70+
});
71+
72+
it("uses the configured provider base URL origin", async () => {
73+
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
74+
apiKey: "minimax-test-key",
75+
source: "env",
76+
mode: "api-key",
77+
});
78+
const fetchMock = vi.fn().mockResolvedValue(
79+
new Response(
80+
JSON.stringify({
81+
data: {
82+
image_base64: [Buffer.from("png-data").toString("base64")],
83+
},
84+
base_resp: { status_code: 0 },
85+
}),
86+
{
87+
status: 200,
88+
headers: { "Content-Type": "application/json" },
89+
},
90+
),
91+
);
92+
vi.stubGlobal("fetch", fetchMock);
93+
94+
const provider = buildMinimaxImageGenerationProvider();
95+
await provider.generateImage({
96+
provider: "minimax",
97+
model: "image-01",
98+
prompt: "draw a cat",
99+
cfg: {
100+
models: {
101+
providers: {
102+
minimax: {
103+
baseUrl: "https://api.minimax.io/anthropic",
104+
models: [],
105+
},
106+
},
107+
},
108+
},
109+
});
110+
111+
expect(fetchMock).toHaveBeenCalledWith(
112+
"https://api.minimax.io/v1/image_generation",
113+
expect.any(Object),
114+
);
115+
});
116+
117+
it("does not allow private-network routing just because a custom base URL is configured", async () => {
118+
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
119+
apiKey: "minimax-test-key",
120+
source: "env",
121+
mode: "api-key",
122+
});
123+
const fetchMock = vi.fn();
124+
vi.stubGlobal("fetch", fetchMock);
125+
126+
const provider = buildMinimaxImageGenerationProvider();
127+
await expect(
128+
provider.generateImage({
129+
provider: "minimax",
130+
model: "image-01",
131+
prompt: "draw a cat",
132+
cfg: {
133+
models: {
134+
providers: {
135+
minimax: {
136+
baseUrl: "http://127.0.0.1:8080/anthropic",
137+
models: [],
138+
},
139+
},
140+
},
141+
},
142+
}),
143+
).rejects.toThrow("Blocked hostname or private/internal/special-use IP address");
144+
145+
expect(fetchMock).not.toHaveBeenCalled();
146+
});
147+
});

0 commit comments

Comments
 (0)