High severity8.3NVD Advisory· Published Mar 31, 2026· Updated Apr 2, 2026
CVE-2026-34504
CVE-2026-34504
Description
OpenClaw before 2026.3.28 contains a server-side request forgery vulnerability in the fal provider image-generation-provider.ts component that allows attackers to fetch internal URLs. A malicious or compromised fal relay can exploit unguarded image download fetches to expose internal service metadata and responses through the image pipeline.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
180d1e8a11a2afal: guard image fetches (#55948)
2 files changed · +386 −117
extensions/fal/image-generation-provider.test.ts+225 −70 modified@@ -1,34 +1,43 @@ import * as providerAuth from "openclaw/plugin-sdk/provider-auth"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildFalImageGenerationProvider } from "./image-generation-provider.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -function expectFalJsonPost( - fetchMock: ReturnType<typeof vi.fn>, - params: { - call: number; - url: string; - body: Record<string, unknown>; - }, -) { - expect(fetchMock).toHaveBeenNthCalledWith( +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +import { + _setFalFetchGuardForTesting, + buildFalImageGenerationProvider, +} from "./image-generation-provider.js"; + +function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) { + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( params.call, - params.url, expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Key fal-test-key", - "Content-Type": "application/json", + 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 = fetchMock.mock.calls[params.call - 1]?.[1]; + const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0]; expect(request).toBeTruthy(); - expect(JSON.parse(String(request?.body))).toEqual(params.body); + expect(JSON.parse(String(request?.init?.body))).toEqual(params.body); } describe("fal image-generation provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + _setFalFetchGuardForTesting(null); vi.restoreAllMocks(); }); @@ -38,26 +47,35 @@ describe("fal image-generation provider", () => { source: "env", mode: "api-key", }); - const fetchMock = vi - .fn() + _setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + const releaseRequest = vi.fn(async () => {}); + const releaseDownload = vi.fn(async () => {}); + fetchWithSsrFGuardMock .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - images: [ - { - url: "https://v3.fal.media/files/example/generated.png", - content_type: "image/png", - }, - ], - prompt: "draw a cat", - }), + response: new Response( + JSON.stringify({ + images: [ + { + url: "https://v3.fal.media/files/example/generated.png", + content_type: "image/png", + }, + ], + prompt: "draw a cat", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: releaseRequest, }) .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: async () => Buffer.from("png-data"), + response: new Response(Buffer.from("png-data"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: releaseDownload, }); - vi.stubGlobal("fetch", fetchMock); const provider = buildFalImageGenerationProvider(); const result = await provider.generateImage({ @@ -69,7 +87,7 @@ describe("fal image-generation provider", () => { size: "1536x1024", }); - expectFalJsonPost(fetchMock, { + expectFalJsonPost({ call: 1, url: "https://fal.run/fal-ai/flux/dev", body: { @@ -79,10 +97,16 @@ describe("fal image-generation provider", () => { output_format: "png", }, }); - expect(fetchMock).toHaveBeenNthCalledWith( + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( 2, - "https://v3.fal.media/files/example/generated.png", + expect.objectContaining({ + url: "https://v3.fal.media/files/example/generated.png", + auditContext: "fal-image-download", + policy: undefined, + }), ); + expect(releaseRequest).toHaveBeenCalledTimes(1); + expect(releaseDownload).toHaveBeenCalledTimes(1); expect(result).toEqual({ images: [ { @@ -102,20 +126,27 @@ describe("fal image-generation provider", () => { source: "env", mode: "api-key", }); - const fetchMock = vi - .fn() + _setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + fetchWithSsrFGuardMock .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - images: [{ url: "https://v3.fal.media/files/example/edited.png" }], - }), + response: new Response( + JSON.stringify({ + images: [{ url: "https://v3.fal.media/files/example/edited.png" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: async () => Buffer.from("edited-data"), + response: new Response(Buffer.from("edited-data"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: vi.fn(async () => {}), }); - vi.stubGlobal("fetch", fetchMock); const provider = buildFalImageGenerationProvider(); await provider.generateImage({ @@ -133,7 +164,7 @@ describe("fal image-generation provider", () => { ], }); - expectFalJsonPost(fetchMock, { + expectFalJsonPost({ call: 1, url: "https://fal.run/fal-ai/flux/dev/image-to-image", body: { @@ -152,20 +183,27 @@ describe("fal image-generation provider", () => { source: "env", mode: "api-key", }); - const fetchMock = vi - .fn() + _setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + fetchWithSsrFGuardMock .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - images: [{ url: "https://v3.fal.media/files/example/wide.png" }], - }), + response: new Response( + JSON.stringify({ + images: [{ url: "https://v3.fal.media/files/example/wide.png" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: async () => Buffer.from("wide-data"), + response: new Response(Buffer.from("wide-data"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: vi.fn(async () => {}), }); - vi.stubGlobal("fetch", fetchMock); const provider = buildFalImageGenerationProvider(); await provider.generateImage({ @@ -176,7 +214,7 @@ describe("fal image-generation provider", () => { aspectRatio: "16:9", }); - expectFalJsonPost(fetchMock, { + expectFalJsonPost({ call: 1, url: "https://fal.run/fal-ai/flux/dev", body: { @@ -194,20 +232,27 @@ describe("fal image-generation provider", () => { source: "env", mode: "api-key", }); - const fetchMock = vi - .fn() + _setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + fetchWithSsrFGuardMock .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - images: [{ url: "https://v3.fal.media/files/example/portrait.png" }], - }), + response: new Response( + JSON.stringify({ + images: [{ url: "https://v3.fal.media/files/example/portrait.png" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), }) .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: async () => Buffer.from("portrait-data"), + response: new Response(Buffer.from("portrait-data"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: vi.fn(async () => {}), }); - vi.stubGlobal("fetch", fetchMock); const provider = buildFalImageGenerationProvider(); await provider.generateImage({ @@ -219,7 +264,7 @@ describe("fal image-generation provider", () => { aspectRatio: "9:16", }); - expectFalJsonPost(fetchMock, { + expectFalJsonPost({ call: 1, url: "https://fal.run/fal-ai/flux/dev", body: { @@ -272,4 +317,114 @@ describe("fal image-generation provider", () => { }), ).rejects.toThrow("does not support aspectRatio overrides"); }); + + it("blocks private-network image download URLs through the SSRF guard", async () => { + vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + _setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + const blocked = new Error("Blocked: resolves to private/internal/special-use IP address"); + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + images: [{ url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }) + .mockRejectedValueOnce(blocked); + + const provider = buildFalImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "draw a cat", + cfg: {}, + }), + ).rejects.toThrow(blocked.message); + + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + auditContext: "fal-image-download", + policy: undefined, + }), + ); + }); + + it("allows trusted private relay hosts derived from 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( + JSON.stringify({ + images: [{ url: "http://media.relay.internal/files/generated.png" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }) + .mockResolvedValueOnce({ + response: new Response(Buffer.from("png-data"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: vi.fn(async () => {}), + }); + + const provider = buildFalImageGenerationProvider(); + await provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "draw a cat", + cfg: { + models: { + providers: { + fal: { + baseUrl: "http://relay.internal:8080", + models: [], + }, + }, + }, + }, + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + url: "http://relay.internal:8080/fal-ai/flux/dev", + auditContext: "fal-image-generate", + policy: relayPolicy, + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://media.relay.internal/files/generated.png", + auditContext: "fal-image-download", + policy: relayPolicy, + }), + ); + }); });
extensions/fal/image-generation-provider.ts+161 −47 modified@@ -2,6 +2,12 @@ import type { GeneratedImageAsset, ImageGenerationProvider, } from "openclaw/plugin-sdk/image-generation"; +import { + buildHostnameAllowlistPolicyFromSuffixAllowlist, + fetchWithSsrFGuard, + type SsrFPolicy, + ssrfPolicyFromAllowPrivateNetwork, +} from "openclaw/plugin-sdk/infra-runtime"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth"; const DEFAULT_FAL_BASE_URL = "https://fal.run"; @@ -28,6 +34,81 @@ type FalImageGenerationResponse = { }; type FalImageSize = string | { width: number; height: number }; +type FalNetworkPolicy = { + apiPolicy?: SsrFPolicy; + trustedDownloadHostSuffix?: string; + trustedDownloadPolicy?: SsrFPolicy; +}; + +let falFetchGuard = fetchWithSsrFGuard; + +export function _setFalFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | null): void { + falFetchGuard = impl ?? fetchWithSsrFGuard; +} + +function mergeSsrFPolicies(...policies: Array<SsrFPolicy | undefined>): SsrFPolicy | undefined { + const merged: SsrFPolicy = {}; + for (const policy of policies) { + if (!policy) { + continue; + } + if (policy.allowPrivateNetwork) { + merged.allowPrivateNetwork = true; + } + if (policy.dangerouslyAllowPrivateNetwork) { + merged.dangerouslyAllowPrivateNetwork = true; + } + if (policy.allowRfc2544BenchmarkRange) { + merged.allowRfc2544BenchmarkRange = true; + } + if (policy.allowedHostnames?.length) { + merged.allowedHostnames = Array.from( + new Set([...(merged.allowedHostnames ?? []), ...policy.allowedHostnames]), + ); + } + if (policy.hostnameAllowlist?.length) { + merged.hostnameAllowlist = Array.from( + new Set([...(merged.hostnameAllowlist ?? []), ...policy.hostnameAllowlist]), + ); + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function matchesTrustedHostSuffix(hostname: string, trustedSuffix: string): boolean { + const normalizedHost = hostname.trim().toLowerCase(); + const normalizedSuffix = trustedSuffix.trim().toLowerCase(); + 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(); + let parsedBaseUrl: URL; + try { + parsedBaseUrl = new URL(baseUrl); + } catch { + return {}; + } + + const hostSuffix = parsedBaseUrl.hostname.trim().toLowerCase(); + if (!hostSuffix) { + return {}; + } + + const hostPolicy = buildHostnameAllowlistPolicyFromSuffixAllowlist([hostSuffix]); + const privateNetworkPolicy = explicitBaseUrl + ? ssrfPolicyFromAllowPrivateNetwork(true) + : undefined; + const trustedHostPolicy = mergeSsrFPolicies(hostPolicy, privateNetworkPolicy); + return { + apiPolicy: trustedHostPolicy, + trustedDownloadHostSuffix: explicitBaseUrl ? hostSuffix : undefined, + trustedDownloadPolicy: explicitBaseUrl ? trustedHostPolicy : undefined, + }; +} function resolveFalBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string { const direct = cfg?.models?.providers?.fal?.baseUrl?.trim(); @@ -174,17 +255,41 @@ function fileExtensionForMimeType(mimeType: string | undefined): string { return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png"; } -async function fetchImageBuffer(url: string): Promise<{ buffer: Buffer; mimeType: string }> { - const response = await fetch(url); - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `fal image download failed (${response.status}): ${text || response.statusText}`, - ); +async function fetchImageBuffer( + url: string, + networkPolicy?: FalNetworkPolicy, +): Promise<{ buffer: Buffer; mimeType: string }> { + const downloadPolicy = (() => { + const trustedSuffix = networkPolicy?.trustedDownloadHostSuffix; + const trustedPolicy = networkPolicy?.trustedDownloadPolicy; + if (!trustedSuffix || !trustedPolicy) { + return undefined; + } + try { + const parsed = new URL(url); + return matchesTrustedHostSuffix(parsed.hostname, trustedSuffix) ? trustedPolicy : undefined; + } catch { + return undefined; + } + })(); + const { response, release } = await falFetchGuard({ + url, + policy: downloadPolicy, + auditContext: "fal-image-download", + }); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `fal image download failed (${response.status}): ${text || response.statusText}`, + ); + } + const mimeType = response.headers.get("content-type")?.trim() || "image/png"; + const arrayBuffer = await response.arrayBuffer(); + return { buffer: Buffer.from(arrayBuffer), mimeType }; + } finally { + await release(); } - const mimeType = response.headers.get("content-type")?.trim() || "image/png"; - const arrayBuffer = await response.arrayBuffer(); - return { buffer: Buffer.from(arrayBuffer), mimeType }; } export function buildFalImageGenerationProvider(): ImageGenerationProvider { @@ -236,6 +341,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { hasInputImages, }); const model = ensureFalModelPath(req.model, hasInputImages); + const networkPolicy = resolveFalNetworkPolicy(req.cfg); const requestBody: Record<string, unknown> = { prompt: req.prompt, num_images: req.count ?? 1, @@ -253,50 +359,58 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { requestBody.image_url = toDataUri(input.buffer, input.mimeType); } - const response = await fetch(`${resolveFalBaseUrl(req.cfg)}/${model}`, { - method: "POST", - headers: { - Authorization: `Key ${auth.apiKey}`, - "Content-Type": "application/json", + const { response, release } = await falFetchGuard({ + url: `${resolveFalBaseUrl(req.cfg)}/${model}`, + init: { + method: "POST", + headers: { + Authorization: `Key ${auth.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), }, - body: JSON.stringify(requestBody), + policy: networkPolicy.apiPolicy, + 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}`, + ); + } - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `fal image generation failed (${response.status}): ${text || response.statusText}`, - ); - } + const payload = (await response.json()) as FalImageGenerationResponse; + const images: GeneratedImageAsset[] = []; + let imageIndex = 0; + for (const entry of payload.images ?? []) { + const url = entry.url?.trim(); + if (!url) { + continue; + } + const downloaded = await fetchImageBuffer(url, networkPolicy); + imageIndex += 1; + images.push({ + buffer: downloaded.buffer, + mimeType: downloaded.mimeType, + fileName: `image-${imageIndex}.${fileExtensionForMimeType( + downloaded.mimeType || entry.content_type, + )}`, + }); + } - const payload = (await response.json()) as FalImageGenerationResponse; - const images: GeneratedImageAsset[] = []; - let imageIndex = 0; - for (const entry of payload.images ?? []) { - const url = entry.url?.trim(); - if (!url) { - continue; + if (images.length === 0) { + throw new Error("fal image generation response missing image data"); } - const downloaded = await fetchImageBuffer(url); - imageIndex += 1; - images.push({ - buffer: downloaded.buffer, - mimeType: downloaded.mimeType, - fileName: `image-${imageIndex}.${fileExtensionForMimeType( - downloaded.mimeType || entry.content_type, - )}`, - }); - } - if (images.length === 0) { - throw new Error("fal image generation response missing image data"); + return { + images, + model, + metadata: payload.prompt ? { prompt: payload.prompt } : undefined, + }; + } finally { + await release(); } - - return { - images, - model, - metadata: payload.prompt ? { prompt: payload.prompt } : undefined, - }; }, }; }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/80d1e8a11a2ac118c7f7a70bba9c862b6141d928nvdPatchWEB
- github.com/advisories/GHSA-qxgf-hmcj-3xw3ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-qxgf-hmcj-3xw3nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-34504ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-server-side-request-forgery-via-unguarded-image-download-in-fal-providernvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.3.28ghsaWEB
News mentions
0No linked articles in our index yet.