Image generation: add fal provider#49454
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 fal image provider allows API key exfiltration / SSRF via unvalidated configurable baseUrl
DescriptionThe fal image generation provider constructs request URLs from Impact if an attacker can influence
Vulnerable code: const direct = cfg?.models?.providers?.fal?.baseUrl?.trim();
return (direct || DEFAULT_FAL_BASE_URL).replace(/\/+$/u, "");
...
await fetch(`${resolveFalBaseUrl(req.cfg)}/${model}`, {
headers: { Authorization: `Key ${auth.apiKey}` },
});Notes on configuration flow (threat model context):
RecommendationFail closed on unsafe Suggested approach:
Example patch sketch: import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
function resolveFalBaseUrlSafe(cfg: OpenClawConfig | undefined): string {
const raw = (cfg?.models?.providers?.fal?.baseUrl ?? DEFAULT_FAL_BASE_URL).trim();
const u = new URL(raw);
if (u.protocol !== "https:") throw new Error("fal baseUrl must be https");
if (u.username || u.password) throw new Error("fal baseUrl must not include userinfo");
// Option A: strict allowlist
const host = u.hostname.toLowerCase();
if (host !== "fal.run" && !host.endsWith(".fal.run")) {
throw new Error("fal baseUrl host is not allowed");
}
return u.toString().replace(/\/+$/u, "");
}
// Then replace raw fetch with guarded fetch
const { response, release } = await fetchWithSsrFGuard({
url: `${resolveFalBaseUrlSafe(req.cfg)}/${model}`,
init: {
method: "POST",
headers: { Authorization: `Key ${auth.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
},
timeoutMs: 60_000,
// Optionally: policy: { hostnameAllowlist: ["fal.run", "*.fal.run"] }
});
try {
// handle response
} finally {
await release();
}If custom endpoints/proxies are required, gate them behind an explicit config flag (e.g., 2. 🟡 SSRF via unvalidated image URL download in fal image provider
DescriptionThe new fal image-generation provider downloads image bytes from a URL returned by the upstream API without any validation or SSRF protection.
Vulnerable code: const downloaded = await fetchImageBuffer(url);
async function fetchImageBuffer(url: string) {
const response = await fetch(url);
...
}RecommendationRoute all remote URL fetching through the existing SSRF-guarded utilities in this repo (they already implement private-network blocking + safe redirect handling with pinned DNS):
Example fix (sketch): import { fetchRemoteMedia } from "../../media/fetch.js";
const FAL_MEDIA_POLICY = {
// Only allow fal media/CDN hosts; do NOT allow private network.
hostnameAllowlist: ["*.fal.media"],
};
async function fetchImageBuffer(url: string) {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error("fal image URL must be https");
}
const result = await fetchRemoteMedia({
url,
maxBytes: 15 * 1024 * 1024, // pick an appropriate cap
maxRedirects: 3,
readIdleTimeoutMs: 10_000,
ssrfPolicy: FAL_MEDIA_POLICY,
});
return {
buffer: result.buffer,
mimeType: result.contentType ?? "image/png",
};
}If fal provides signed URLs or an SDK method to fetch assets safely, prefer that over arbitrary URL fetching. 3. 🟡 fal image provider allows unbounded image dimensions/count leading to resource & cost exhaustion
DescriptionThe fal image-generation provider forwards caller-controlled sizing and image-count parameters to the upstream API without enforcing the provider’s own advertised limits ( Impact:
Vulnerable code (size parsing accepts up to 5 digits per dimension and returns it unbounded): const match = /^(\d{2,5})x(\d{2,5})$/iu.exec(trimmed);
...
return { width, height };Related sink (unbounded forwarding of size/count):
Call chain / existing validation:
RecommendationEnforce strict provider-side limits before sending requests upstream, and cap downloads.
const SUPPORTED_SIZES = new Set(["1024x1024","1024x1536","1536x1024","1024x1792","1792x1024"]);
function resolveSafeFalImageSize(size?: string, resolution?: "1K"|"2K"|"4K") {
const trimmed = size?.trim();
if (trimmed && SUPPORTED_SIZES.has(trimmed)) {
const [w,h] = trimmed.split("x").map((v) => Number.parseInt(v, 10));
return { width: w, height: h };
}
// fall back to resolution mapping / default
return mapResolutionToSize(resolution) ?? DEFAULT_OUTPUT_SIZE;
}
const MAX_COUNT = 4;
const count = Math.min(Math.max(1, req.count ?? 1), MAX_COUNT);
import { fetchRemoteMedia, MAX_IMAGE_BYTES } from "../../media/fetch.js";
// or a shared helper that enforces maxBytesThis ensures attacker-controlled or unexpected upstream responses cannot exhaust memory. Analyzed PR: #49454 at commit Last updated on: 2026-03-18T05:26:26Z |
6dec0b9 to
c546c18
Compare
Greptile SummaryThis PR adds a bundled Key findings:
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 83-93
Comment:
**MIME type parameters not stripped from file extension**
`fileExtensionForMimeType` takes the raw `Content-Type` header value from `fetchImageBuffer`, which can include parameters separated by `;` (e.g. `image/png; charset=UTF-8`). The current slicing logic will produce a file name like `image-1.png; charset=utf-8` because it only looks for the `/` separator and doesn't account for `;`-delimited parameters.
While CDNs rarely add such parameters to image responses, the fal CDN is not guaranteed to omit them forever. Splitting on `;` before slicing would make this robust:
```suggestion
function fileExtensionForMimeType(mimeType: string | undefined): string {
const normalized = mimeType?.toLowerCase().trim().split(";")[0]?.trim();
if (!normalized) {
return "png";
}
if (normalized.includes("jpeg")) {
return "jpg";
}
const slashIndex = normalized.indexOf("/");
return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png";
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 178-184
Comment:
**Dead fallback to `entry.content_type`**
`fetchImageBuffer` always returns a non-empty `mimeType` — it defaults to `"image/png"` when the `Content-Type` header is absent. This means `downloaded.mimeType` is always truthy and `entry.content_type` is never reached. The fallback creates a misleading impression that there is a safety net here.
```suggestion
fileName: `image-${imageIndex}.${fileExtensionForMimeType(downloaded.mimeType)}`,
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 171-185
Comment:
**Sequential image downloads for multi-image requests**
When `count > 1` and fal returns multiple image URLs, each URL is downloaded sequentially with a separate `await fetchImageBuffer(url)` inside the `for...of` loop. For a request that returns, say, 4 images, this serialises 4 independent HTTP fetches. Using `Promise.all` over the filtered URLs would download them in parallel and noticeably reduce latency for multi-image responses:
```typescript
const urls = (payload.images ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
const downloaded = await Promise.all(urls.map((url) => fetchImageBuffer(url)));
const images: GeneratedImageAsset[] = downloaded.map((dl, i) => ({
buffer: dl.buffer,
mimeType: dl.mimeType,
fileName: `image-${i + 1}.${fileExtensionForMimeType(dl.mimeType)}`,
}));
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: "Image generation: ad..." |
| function fileExtensionForMimeType(mimeType: string | undefined): string { | ||
| const normalized = mimeType?.toLowerCase().trim(); | ||
| if (!normalized) { | ||
| return "png"; | ||
| } | ||
| if (normalized.includes("jpeg")) { | ||
| return "jpg"; | ||
| } | ||
| const slashIndex = normalized.indexOf("/"); | ||
| return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png"; | ||
| } |
There was a problem hiding this comment.
MIME type parameters not stripped from file extension
fileExtensionForMimeType takes the raw Content-Type header value from fetchImageBuffer, which can include parameters separated by ; (e.g. image/png; charset=UTF-8). The current slicing logic will produce a file name like image-1.png; charset=utf-8 because it only looks for the / separator and doesn't account for ;-delimited parameters.
While CDNs rarely add such parameters to image responses, the fal CDN is not guaranteed to omit them forever. Splitting on ; before slicing would make this robust:
| function fileExtensionForMimeType(mimeType: string | undefined): string { | |
| const normalized = mimeType?.toLowerCase().trim(); | |
| if (!normalized) { | |
| return "png"; | |
| } | |
| if (normalized.includes("jpeg")) { | |
| return "jpg"; | |
| } | |
| const slashIndex = normalized.indexOf("/"); | |
| return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png"; | |
| } | |
| function fileExtensionForMimeType(mimeType: string | undefined): string { | |
| const normalized = mimeType?.toLowerCase().trim().split(";")[0]?.trim(); | |
| if (!normalized) { | |
| return "png"; | |
| } | |
| if (normalized.includes("jpeg")) { | |
| return "jpg"; | |
| } | |
| const slashIndex = normalized.indexOf("/"); | |
| return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png"; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 83-93
Comment:
**MIME type parameters not stripped from file extension**
`fileExtensionForMimeType` takes the raw `Content-Type` header value from `fetchImageBuffer`, which can include parameters separated by `;` (e.g. `image/png; charset=UTF-8`). The current slicing logic will produce a file name like `image-1.png; charset=utf-8` because it only looks for the `/` separator and doesn't account for `;`-delimited parameters.
While CDNs rarely add such parameters to image responses, the fal CDN is not guaranteed to omit them forever. Splitting on `;` before slicing would make this robust:
```suggestion
function fileExtensionForMimeType(mimeType: string | undefined): string {
const normalized = mimeType?.toLowerCase().trim().split(";")[0]?.trim();
if (!normalized) {
return "png";
}
if (normalized.includes("jpeg")) {
return "jpg";
}
const slashIndex = normalized.indexOf("/");
return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || "png" : "png";
}
```
How can I resolve this? If you propose a fix, please make it concise.| images.push({ | ||
| buffer: downloaded.buffer, | ||
| mimeType: downloaded.mimeType, | ||
| fileName: `image-${imageIndex}.${fileExtensionForMimeType( | ||
| downloaded.mimeType || entry.content_type, | ||
| )}`, | ||
| }); |
There was a problem hiding this comment.
Dead fallback to
entry.content_type
fetchImageBuffer always returns a non-empty mimeType — it defaults to "image/png" when the Content-Type header is absent. This means downloaded.mimeType is always truthy and entry.content_type is never reached. The fallback creates a misleading impression that there is a safety net here.
| images.push({ | |
| buffer: downloaded.buffer, | |
| mimeType: downloaded.mimeType, | |
| fileName: `image-${imageIndex}.${fileExtensionForMimeType( | |
| downloaded.mimeType || entry.content_type, | |
| )}`, | |
| }); | |
| fileName: `image-${imageIndex}.${fileExtensionForMimeType(downloaded.mimeType)}`, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 178-184
Comment:
**Dead fallback to `entry.content_type`**
`fetchImageBuffer` always returns a non-empty `mimeType` — it defaults to `"image/png"` when the `Content-Type` header is absent. This means `downloaded.mimeType` is always truthy and `entry.content_type` is never reached. The fallback creates a misleading impression that there is a safety net here.
```suggestion
fileName: `image-${imageIndex}.${fileExtensionForMimeType(downloaded.mimeType)}`,
```
How can I resolve this? If you propose a fix, please make it concise.| for (const entry of payload.images ?? []) { | ||
| const url = entry.url?.trim(); | ||
| if (!url) { | ||
| continue; | ||
| } | ||
| const downloaded = await fetchImageBuffer(url); | ||
| imageIndex += 1; | ||
| images.push({ | ||
| buffer: downloaded.buffer, | ||
| mimeType: downloaded.mimeType, | ||
| fileName: `image-${imageIndex}.${fileExtensionForMimeType( | ||
| downloaded.mimeType || entry.content_type, | ||
| )}`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Sequential image downloads for multi-image requests
When count > 1 and fal returns multiple image URLs, each URL is downloaded sequentially with a separate await fetchImageBuffer(url) inside the for...of loop. For a request that returns, say, 4 images, this serialises 4 independent HTTP fetches. Using Promise.all over the filtered URLs would download them in parallel and noticeably reduce latency for multi-image responses:
const urls = (payload.images ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
const downloaded = await Promise.all(urls.map((url) => fetchImageBuffer(url)));
const images: GeneratedImageAsset[] = downloaded.map((dl, i) => ({
buffer: dl.buffer,
mimeType: dl.mimeType,
fileName: `image-${i + 1}.${fileExtensionForMimeType(dl.mimeType)}`,
}));Prompt To Fix With AI
This is a comment left during a code review.
Path: src/image-generation/providers/fal.ts
Line: 171-185
Comment:
**Sequential image downloads for multi-image requests**
When `count > 1` and fal returns multiple image URLs, each URL is downloaded sequentially with a separate `await fetchImageBuffer(url)` inside the `for...of` loop. For a request that returns, say, 4 images, this serialises 4 independent HTTP fetches. Using `Promise.all` over the filtered URLs would download them in parallel and noticeably reduce latency for multi-image responses:
```typescript
const urls = (payload.images ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
const downloaded = await Promise.all(urls.map((url) => fetchImageBuffer(url)));
const images: GeneratedImageAsset[] = downloaded.map((dl, i) => ({
buffer: dl.buffer,
mimeType: dl.mimeType,
fileName: `image-${i + 1}.${fileExtensionForMimeType(dl.mimeType)}`,
}));
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6dec0b96e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| flagName: "--fal-api-key", | ||
| envVar: "FAL_KEY", | ||
| promptMessage: "Enter fal API key", | ||
| defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, |
There was a problem hiding this comment.
Remove text default model from fal API-key setup
Passing defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF into createProviderApiKeyAuthMethod makes auth setup treat fal/fal-ai/flux/dev as the agent's default chat model (via applyPrimaryModel in src/plugins/provider-api-key-auth.ts), not just the image-generation default. In non-interactive onboarding, and in interactive flows that apply defaults, this can rewrite agents.defaults.model.primary to a provider that has no text-generation runtime, causing normal agent/model calls to fail after fal auth. Keep the image default in applyFalConfig (agents.defaults.imageGenerationModel) and do not set defaultModel here.
Useful? React with 👍 / 👎.
Summary
falprovider support for coreimage_generateFAL_KEYand default onboarding tofal/fal-ai/flux/devTesting
pnpm exec oxfmt --check src/image-generation/providers/fal.ts src/image-generation/providers/fal.test.ts extensions/fal/index.ts extensions/fal/onboard.ts