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
This commit is contained in:
Vincent Koc
2026-04-03 00:32:37 +09:00
committed by GitHub
parent d2ce3e9acc
commit 0ad2dbd307
9 changed files with 499 additions and 160 deletions

View File

@@ -11,23 +11,14 @@ import {
} from "./image-generation-provider.js";
function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) {
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
params.call,
expect.objectContaining({
url: params.url,
init: expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Key fal-test-key",
"Content-Type": "application/json",
}),
}),
auditContext: "fal-image-generate",
}),
);
const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0];
expect(request).toBeTruthy();
expect(request?.url).toBe(params.url);
expect(request?.auditContext).toBe("fal-image-generate");
expect(request?.init?.method).toBe("POST");
const headers = new Headers(request?.init?.headers);
expect(headers.get("authorization")).toBe("Key fal-test-key");
expect(headers.get("content-type")).toBe("application/json");
expect(JSON.parse(String(request?.init?.body))).toEqual(params.body);
}
@@ -361,17 +352,13 @@ describe("fal image-generation provider", () => {
);
});
it("allows trusted private relay hosts derived from configured baseUrl", async () => {
it("does not auto-whitelist trusted private relay hosts from a configured baseUrl", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
_setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
const relayPolicy = {
allowPrivateNetwork: true,
hostnameAllowlist: ["relay.internal", "*.relay.internal"],
};
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -415,7 +402,7 @@ describe("fal image-generation provider", () => {
expect.objectContaining({
url: "http://relay.internal:8080/fal-ai/flux/dev",
auditContext: "fal-image-generate",
policy: relayPolicy,
policy: undefined,
}),
);
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
@@ -423,7 +410,7 @@ describe("fal image-generation provider", () => {
expect.objectContaining({
url: "http://media.relay.internal/files/generated.png",
auditContext: "fal-image-download",
policy: relayPolicy,
policy: undefined,
}),
);
});

View File

@@ -3,6 +3,10 @@ import type {
ImageGenerationProvider,
} from "openclaw/plugin-sdk/image-generation";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
fetchWithSsrFGuard,
@@ -81,40 +85,32 @@ function matchesTrustedHostSuffix(hostname: string, trustedSuffix: string): bool
return normalizedHost === normalizedSuffix || normalizedHost.endsWith(`.${normalizedSuffix}`);
}
function resolveFalNetworkPolicy(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
): FalNetworkPolicy {
const baseUrl = resolveFalBaseUrl(cfg);
const explicitBaseUrl = cfg?.models?.providers?.fal?.baseUrl?.trim();
function resolveFalNetworkPolicy(params: {
baseUrl: string;
allowPrivateNetwork: boolean;
}): FalNetworkPolicy {
let parsedBaseUrl: URL;
try {
parsedBaseUrl = new URL(baseUrl);
parsedBaseUrl = new URL(params.baseUrl);
} catch {
return {};
}
const hostSuffix = parsedBaseUrl.hostname.trim().toLowerCase();
if (!hostSuffix) {
if (!hostSuffix || !params.allowPrivateNetwork) {
return {};
}
const hostPolicy = buildHostnameAllowlistPolicyFromSuffixAllowlist([hostSuffix]);
const privateNetworkPolicy = explicitBaseUrl
? ssrfPolicyFromAllowPrivateNetwork(true)
: undefined;
const privateNetworkPolicy = ssrfPolicyFromAllowPrivateNetwork(true);
const trustedHostPolicy = mergeSsrFPolicies(hostPolicy, privateNetworkPolicy);
return {
apiPolicy: trustedHostPolicy,
trustedDownloadHostSuffix: explicitBaseUrl ? hostSuffix : undefined,
trustedDownloadPolicy: explicitBaseUrl ? trustedHostPolicy : undefined,
trustedDownloadHostSuffix: hostSuffix,
trustedDownloadPolicy: trustedHostPolicy,
};
}
function resolveFalBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string {
const direct = cfg?.models?.providers?.fal?.baseUrl?.trim();
return (direct || DEFAULT_FAL_BASE_URL).replace(/\/+$/u, "");
}
function ensureFalModelPath(model: string | undefined, hasInputImages: boolean): string {
const trimmed = model?.trim() || DEFAULT_FAL_IMAGE_MODEL;
if (!hasInputImages) {
@@ -341,7 +337,21 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
hasInputImages,
});
const model = ensureFalModelPath(req.model, hasInputImages);
const networkPolicy = resolveFalNetworkPolicy(req.cfg);
const explicitBaseUrl = req.cfg?.models?.providers?.fal?.baseUrl?.trim();
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: explicitBaseUrl,
defaultBaseUrl: DEFAULT_FAL_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Key ${auth.apiKey}`,
"Content-Type": "application/json",
},
provider: "fal",
capability: "image",
transport: "http",
});
const networkPolicy = resolveFalNetworkPolicy({ baseUrl, allowPrivateNetwork });
const requestBody: Record<string, unknown> = {
prompt: req.prompt,
num_images: req.count ?? 1,
@@ -358,27 +368,20 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
}
requestBody.image_url = toDataUri(input.buffer, input.mimeType);
}
const { response, release } = await falFetchGuard({
url: `${resolveFalBaseUrl(req.cfg)}/${model}`,
url: `${baseUrl}/${model}`,
init: {
method: "POST",
headers: {
Authorization: `Key ${auth.apiKey}`,
"Content-Type": "application/json",
},
headers,
body: JSON.stringify(requestBody),
},
timeoutMs: req.timeoutMs,
policy: networkPolicy.apiPolicy,
dispatcherPolicy,
auditContext: "fal-image-generate",
});
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`fal image generation failed (${response.status}): ${text || response.statusText}`,
);
}
await assertOkOrThrowHttpError(response, "fal image generation failed");
const payload = (await response.json()) as FalImageGenerationResponse;
const images: GeneratedImageAsset[] = [];