Skip to content

Commit dcea6b4

Browse files
committed
fix(fal): bound image/music/video generation response reads
The image, music, and video generation providers read successful fal API JSON bodies via an unbounded await response.json() after the HTTP status check. A hostile or buggy fal endpoint can stream an arbitrarily large success body with no Content-Length, forcing the runtime to buffer the entire payload in memory before parsing and risking OOM/hang. Route all three success-path reads through the shared readProviderJsonResponse helper, which reads the body through the same 16 MiB bounded reader used for binary responses and cancels the upstream stream on overflow. The video path preserves its existing FAL_VIDEO_MALFORMED_RESPONSE mapping for malformed JSON while letting the overflow error propagate. Tests updated to stream real Response bodies.
1 parent 6830aa3 commit dcea6b4

5 files changed

Lines changed: 58 additions & 55 deletions

extensions/fal/image-generation-provider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
1212
import {
1313
assertOkOrThrowHttpError,
1414
assertOkOrThrowProviderError,
15+
readProviderJsonResponse,
1516
} from "openclaw/plugin-sdk/provider-http";
1617
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
1718
import {
@@ -645,7 +646,9 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
645646
try {
646647
await assertOkOrThrowHttpError(response, "fal image generation failed");
647648

648-
const payload = parseFalImageGenerationResponse(await response.json());
649+
const payload = parseFalImageGenerationResponse(
650+
await readProviderJsonResponse<unknown>(response, "fal image generation"),
651+
);
649652
const images: GeneratedImageAsset[] = [];
650653
let imageIndex = 0;
651654
for (const entry of payload.images) {

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

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ function streamedAudioResponse(bytes: string): Response {
5858
);
5959
}
6060

61+
function falMusicJsonResponse(value: unknown) {
62+
return {
63+
response: Response.json(value),
64+
release: vi.fn(async () => {}),
65+
};
66+
}
67+
6168
describe("fal music generation provider", () => {
6269
afterEach(() => {
6370
assertOkOrThrowHttpErrorMock.mockClear();
@@ -72,18 +79,15 @@ describe("fal music generation provider", () => {
7279
});
7380

7481
it("submits MiniMax music through fal and downloads the generated track", async () => {
75-
postJsonRequestMock.mockResolvedValue({
76-
response: {
77-
json: async () => ({
78-
audio: {
79-
url: "https://v3b.fal.media/files/b/kangaroo/out.mp3",
80-
content_type: "audio/mpeg",
81-
file_name: "out.mp3",
82-
},
83-
}),
84-
},
85-
release: vi.fn(async () => {}),
86-
});
82+
postJsonRequestMock.mockResolvedValue(
83+
falMusicJsonResponse({
84+
audio: {
85+
url: "https://v3b.fal.media/files/b/kangaroo/out.mp3",
86+
content_type: "audio/mpeg",
87+
file_name: "out.mp3",
88+
},
89+
}),
90+
);
8791
const fetchMock = vi.fn(
8892
async () =>
8993
new Response(Buffer.from("mp3-bytes"), {
@@ -125,17 +129,14 @@ describe("fal music generation provider", () => {
125129
});
126130

127131
it("rejects generated music downloads that exceed the configured media cap", async () => {
128-
postJsonRequestMock.mockResolvedValue({
129-
response: {
130-
json: async () => ({
131-
audio: {
132-
url: "https://v3b.fal.media/files/b/out.mp3",
133-
content_type: "audio/mpeg",
134-
},
135-
}),
136-
},
137-
release: vi.fn(async () => {}),
138-
});
132+
postJsonRequestMock.mockResolvedValue(
133+
falMusicJsonResponse({
134+
audio: {
135+
url: "https://v3b.fal.media/files/b/out.mp3",
136+
content_type: "audio/mpeg",
137+
},
138+
}),
139+
);
139140
vi.stubGlobal(
140141
"fetch",
141142
vi.fn(async () => streamedAudioResponse("too-large")),
@@ -167,16 +168,13 @@ describe("fal music generation provider", () => {
167168
});
168169

169170
it("maps ACE-Step duration and instrumental controls", async () => {
170-
postJsonRequestMock.mockResolvedValue({
171-
response: {
172-
json: async () => ({
173-
audio: { url: "https://example.com/out.wav", content_type: "audio/wav" },
174-
seed: 42,
175-
tags: "lofi, chill",
176-
}),
177-
},
178-
release: vi.fn(async () => {}),
179-
});
171+
postJsonRequestMock.mockResolvedValue(
172+
falMusicJsonResponse({
173+
audio: { url: "https://example.com/out.wav", content_type: "audio/wav" },
174+
seed: 42,
175+
tags: "lofi, chill",
176+
}),
177+
);
180178
vi.stubGlobal(
181179
"fetch",
182180
vi.fn(
@@ -205,14 +203,11 @@ describe("fal music generation provider", () => {
205203
});
206204

207205
it("maps Stable Audio duration controls", async () => {
208-
postJsonRequestMock.mockResolvedValue({
209-
response: {
210-
json: async () => ({
211-
audio: "https://example.com/stable.wav",
212-
}),
213-
},
214-
release: vi.fn(async () => {}),
215-
});
206+
postJsonRequestMock.mockResolvedValue(
207+
falMusicJsonResponse({
208+
audio: "https://example.com/stable.wav",
209+
}),
210+
);
216211
vi.stubGlobal(
217212
"fetch",
218213
vi.fn(

extensions/fal/music-generation-provider.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
type MusicGenerationRequest,
77
} from "openclaw/plugin-sdk/music-generation";
88
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
9-
import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
9+
import {
10+
assertOkOrThrowHttpError,
11+
postJsonRequest,
12+
readProviderJsonResponse,
13+
} from "openclaw/plugin-sdk/provider-http";
1014
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1115
import { resolveFalHttpRequestConfig } from "./http-config.js";
1216

@@ -161,7 +165,7 @@ export function buildFalMusicGenerationProvider(): MusicGenerationProvider {
161165

162166
try {
163167
await assertOkOrThrowHttpError(response, "fal music generation failed");
164-
const payload = await response.json();
168+
const payload = await readProviderJsonResponse<unknown>(response, "fal music generation");
165169
const [candidate] = extractGeneratedMusicFileCandidates(payload);
166170
if (!candidate) {
167171
throw new Error("fal music generation response missing audio output");

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ describe("fal video generation provider", () => {
3737

3838
function releasedJson(value: unknown) {
3939
return {
40-
response: {
41-
json: async () => value,
42-
},
40+
response: Response.json(value),
4341
release: vi.fn(async () => {}),
4442
};
4543
}
@@ -254,11 +252,10 @@ describe("fal video generation provider", () => {
254252
it("wraps non-JSON successful fal submit responses", async () => {
255253
mockFalProviderRuntime();
256254
fetchGuardMock.mockResolvedValueOnce({
257-
response: {
258-
json: async () => {
259-
throw new SyntaxError("Unexpected token < in JSON");
260-
},
261-
},
255+
response: new Response("<html><body>Bad Gateway</body></html>", {
256+
status: 200,
257+
headers: { "content-type": "text/html" },
258+
}),
262259
release: vi.fn(async () => {}),
263260
});
264261

extensions/fal/video-generation-provider.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
55
import {
66
assertOkOrThrowHttpError,
77
createProviderOperationDeadline,
8+
readProviderJsonResponse,
89
type ProviderOperationDeadline,
910
} from "openclaw/plugin-sdk/provider-http";
1011
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
@@ -480,9 +481,12 @@ async function fetchFalJson(params: {
480481
try {
481482
await assertOkOrThrowHttpError(response, params.errorContext);
482483
try {
483-
return await response.json();
484-
} catch {
485-
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE);
484+
return await readProviderJsonResponse<unknown>(response, params.errorContext);
485+
} catch (error) {
486+
if (error instanceof Error && error.message.endsWith(": malformed JSON response")) {
487+
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE, { cause: error });
488+
}
489+
throw error;
486490
}
487491
} finally {
488492
await release();

0 commit comments

Comments
 (0)