mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM (#95416)
* fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM * fix(inworld): enforce decoded TTS audio cap --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
parent
0f619eaa15
commit
4c8470c069
2 changed files with 224 additions and 5 deletions
|
|
@ -343,3 +343,140 @@ describe("inworldTTS", () => {
|
|||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Inworld response read bounding", () => {
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
// A never-ending stream that enqueues one fixed-size chunk per pull. An
|
||||
// unbounded reader (the previous `await response.text()` / `response.json()`)
|
||||
// would buffer this forever and OOM; the bounded reader must stop at the cap
|
||||
// and cancel the stream.
|
||||
function infiniteByteStream(chunkBytes: number): {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
state: { enqueued: number; cancelled: boolean };
|
||||
} {
|
||||
const state = { enqueued: 0, cancelled: false };
|
||||
const chunk = new Uint8Array(chunkBytes).fill(0x61); // "a"
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
state.enqueued += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { stream, state };
|
||||
}
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized TTS audio stream instead of buffering it (32 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS audio stream too large: \d+ bytes \(limit: 33554432 bytes\)/,
|
||||
);
|
||||
// Enforced after a bounded number of 8 MiB chunks, never the full unbounded
|
||||
// stream, and the stream is cancelled so the socket/buffers are released.
|
||||
expect(state.enqueued).toBeLessThanOrEqual(8);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal multi-line NDJSON audio payload still decodes unchanged", async () => {
|
||||
const part1 = Buffer.from("hello-").toString("base64");
|
||||
const part2 = Buffer.from("world").toString("base64");
|
||||
const body = [
|
||||
JSON.stringify({ result: { audioContent: part1 } }),
|
||||
JSON.stringify({ result: { audioContent: part2 } }),
|
||||
].join("\n");
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.toString("utf8")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("edge: an under-cap ~1 MiB audio payload is read intact, not truncated", async () => {
|
||||
const payload = "x".repeat(MiB);
|
||||
const encoded = Buffer.from(payload).toString("base64");
|
||||
const body = JSON.stringify({ result: { audioContent: encoded } });
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.length).toBe(payload.length);
|
||||
expect(audio.toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects decoded audio that exceeds the shared audio cap", async () => {
|
||||
const decodedPayload = Buffer.alloc(16 * MiB + 1, 0x61);
|
||||
const body = JSON.stringify({
|
||||
result: { audioContent: decodedPayload.toString("base64") },
|
||||
});
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS decoded audio too large: 16777217 bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("regression: a malformed NDJSON line under the cap still throws a bounded parse error", async () => {
|
||||
queueGuardedResponse(new Response("this-is-not-json", { status: 200 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS stream parse error/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: truncates an oversized HTTP error body to a bounded marker", async () => {
|
||||
queueGuardedResponse(new Response("E".repeat(64 * 1024), { status: 500 }));
|
||||
|
||||
let captured: unknown;
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" }).catch((error: unknown) => {
|
||||
captured = error;
|
||||
});
|
||||
|
||||
expect(captured).toBeInstanceOf(Error);
|
||||
const message = (captured as Error).message;
|
||||
expect(message.startsWith("Inworld TTS API error (500): ")).toBe(true);
|
||||
// Never the full 64 KiB hostile body: it collapses to a fixed marker.
|
||||
expect(message).toContain("(error body exceeded diagnostic limit; truncated)");
|
||||
expect(message.length).toBeLessThan(512);
|
||||
});
|
||||
|
||||
it("edge: a small error body is preserved verbatim in the thrown message", async () => {
|
||||
queueGuardedResponse(new Response("invalid api key", { status: 401 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (401): invalid api key",
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized voices JSON stream (16 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld voices response too large: \d+ bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
expect(state.enqueued).toBeLessThanOrEqual(4);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal voices JSON list still parses unchanged", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [{ voiceId: "Sarah", displayName: "Sarah", langCode: "en-US", tags: ["female"] }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toEqual([
|
||||
{ id: "Sarah", name: "Sarah", description: undefined, locale: "en-US", gender: "female" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("regression: malformed voices JSON under the cap still throws", async () => {
|
||||
queueGuardedResponse(new Response("{not-json", { status: 200 }));
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// Inworld plugin module implements tts behavior.
|
||||
import { MAX_AUDIO_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
|
|
@ -6,6 +8,60 @@ const DEFAULT_INWORLD_BASE_URL = "https://api.inworld.ai";
|
|||
export const DEFAULT_INWORLD_VOICE_ID = "Sarah";
|
||||
export const DEFAULT_INWORLD_MODEL_ID = "inworld-tts-1.5-max";
|
||||
|
||||
// The streaming TTS endpoint returns newline-delimited JSON whose audio is
|
||||
// base64-encoded, so the wire body is ~4/3 larger than the decoded audio plus a
|
||||
// JSON envelope. Cap the read at double the shared 16 MiB audio limit so a
|
||||
// full-size legitimate clip still fits, while bounding memory against an
|
||||
// unbounded or hijacked SSE stream that would otherwise be buffered whole by the
|
||||
// previous `await response.text()`.
|
||||
const INWORLD_TTS_BODY_MAX_BYTES = MAX_AUDIO_BYTES * 2;
|
||||
// The voices listing is a small JSON catalog, so the shared 16 MiB audio limit
|
||||
// is already generous headroom while still closing the unbounded
|
||||
// `await response.json()` read.
|
||||
const INWORLD_VOICES_BODY_MAX_BYTES = MAX_AUDIO_BYTES;
|
||||
// Abort the read if the upstream stalls mid-body so a hung stream cannot pin the
|
||||
// socket and buffers open indefinitely.
|
||||
const INWORLD_BODY_READ_IDLE_TIMEOUT_MS = 30_000;
|
||||
// Error responses only need a short diagnostic snippet, never the whole body.
|
||||
const INWORLD_ERROR_BODY_MAX_BYTES = 8 * 1024;
|
||||
const INWORLD_ERROR_BODY_MAX_CHARS = 400;
|
||||
const INWORLD_ERROR_BODY_READ_IDLE_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Sentinel so the error-snippet reader can tell a cap overflow apart from an
|
||||
// unrelated read failure without leaking the (possibly hostile) body.
|
||||
class InworldErrorBodyOverflow extends Error {}
|
||||
|
||||
/**
|
||||
* Reads a bounded, whitespace-collapsed diagnostic snippet from a non-OK
|
||||
* response body. A misbehaving or hostile endpoint can stream an arbitrarily
|
||||
* large error body, so this never buffers it whole: it reuses the shared
|
||||
* `readResponseWithLimit` reader (which cancels the underlying stream on
|
||||
* overflow and enforces an idle timeout) with a small cap. On overflow it
|
||||
* returns a fixed marker instead of echoing attacker-controlled bytes into the
|
||||
* thrown error. Kept local to this extension so it depends only on the
|
||||
* already-exported `response-limit-runtime` entry and adds no shared plugin-SDK
|
||||
* surface.
|
||||
*/
|
||||
async function readInworldErrorBodySnippet(response: Response): Promise<string> {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await readResponseWithLimit(response, INWORLD_ERROR_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_ERROR_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: () => new InworldErrorBodyOverflow(),
|
||||
});
|
||||
} catch (error) {
|
||||
return error instanceof InworldErrorBodyOverflow
|
||||
? "(error body exceeded diagnostic limit; truncated)"
|
||||
: "";
|
||||
}
|
||||
|
||||
const collapsed = buffer.toString("utf8").replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length > INWORLD_ERROR_BODY_MAX_CHARS) {
|
||||
return `${collapsed.slice(0, INWORLD_ERROR_BODY_MAX_CHARS)}…`;
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export const INWORLD_TTS_MODELS = [
|
||||
"inworld-tts-1.5-max",
|
||||
"inworld-tts-1.5-mini",
|
||||
|
|
@ -90,12 +146,21 @@ export async function inworldTTS(params: {
|
|||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
const errorBody = await readInworldErrorBodySnippet(response);
|
||||
throw new Error(`Inworld TTS API error (${response.status}): ${errorBody}`);
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
const body = (
|
||||
await readResponseWithLimit(response, INWORLD_TTS_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(`Inworld TTS audio stream too large: ${size} bytes (limit: ${maxBytes} bytes)`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`Inworld TTS audio stream stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
})
|
||||
).toString("utf8");
|
||||
const chunks: Buffer[] = [];
|
||||
let decodedAudioBytes = 0;
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
|
@ -120,7 +185,15 @@ export async function inworldTTS(params: {
|
|||
}
|
||||
|
||||
if (parsed.result?.audioContent) {
|
||||
chunks.push(Buffer.from(parsed.result.audioContent, "base64"));
|
||||
const chunk = Buffer.from(parsed.result.audioContent, "base64");
|
||||
const nextDecodedAudioBytes = decodedAudioBytes + chunk.length;
|
||||
if (nextDecodedAudioBytes > MAX_AUDIO_BYTES) {
|
||||
throw new Error(
|
||||
`Inworld TTS decoded audio too large: ${nextDecodedAudioBytes} bytes (limit: ${MAX_AUDIO_BYTES} bytes)`,
|
||||
);
|
||||
}
|
||||
decodedAudioBytes = nextDecodedAudioBytes;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,11 +232,20 @@ export async function listInworldVoices(params: {
|
|||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
const errorBody = await readInworldErrorBodySnippet(response);
|
||||
throw new Error(`Inworld voices API error (${response.status}): ${errorBody}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
const voicesBody = (
|
||||
await readResponseWithLimit(response, INWORLD_VOICES_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(`Inworld voices response too large: ${size} bytes (limit: ${maxBytes} bytes)`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`Inworld voices response stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
})
|
||||
).toString("utf8");
|
||||
const json = JSON.parse(voicesBody) as {
|
||||
voices?: Array<{
|
||||
voiceId?: string;
|
||||
displayName?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue