mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-29 01:31:18 +00:00
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:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user