diff --git a/extensions/volcengine/tts.test.ts b/extensions/volcengine/tts.test.ts index b8906fe79edf..af5c72efe288 100644 --- a/extensions/volcengine/tts.test.ts +++ b/extensions/volcengine/tts.test.ts @@ -7,6 +7,8 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), })); +const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; + vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); @@ -45,6 +47,18 @@ function clearTtsEnv() { delete process.env.VOLCENGINE_TTS_TOKEN; } +function makeOversizedStreamResponse(): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }), + ); +} + function restoreOptionalEnv(key: string, value: string | undefined) { if (value === undefined) { delete process.env[key]; @@ -301,6 +315,23 @@ describe("volcengineTTS", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("bounds Seed Speech success response reads", async () => { + const release = vi.fn(); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: makeOversizedStreamResponse(), + release, + }); + + await expect( + volcengineTTS({ + text: "hello", + apiKey: "secret-api-key", + timeoutMs: 1000, + }), + ).rejects.toThrow("BytePlus Seed Speech TTS response exceeds 16777216 bytes"); + expect(release).toHaveBeenCalledTimes(1); + }); + it("reports provider errors without exposing credentials", async () => { const release = vi.fn(); fetchWithSsrFGuardMock.mockResolvedValue({ @@ -327,4 +358,22 @@ describe("volcengineTTS", () => { expect((error as Error).message).not.toContain("secret-token"); expect(release).toHaveBeenCalledTimes(1); }); + + it("bounds legacy Volcengine success response reads", async () => { + const release = vi.fn(); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: makeOversizedStreamResponse(), + release, + }); + + await expect( + volcengineTTS({ + text: "hello", + appId: "app-id", + token: "secret-token", + timeoutMs: 1000, + }), + ).rejects.toThrow("Volcengine TTS response exceeds 16777216 bytes"); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/volcengine/tts.ts b/extensions/volcengine/tts.ts index ac7e409d7164..74f77ace3a01 100644 --- a/extensions/volcengine/tts.ts +++ b/extensions/volcengine/tts.ts @@ -1,5 +1,6 @@ // Volcengine plugin module implements tts behavior. import * as crypto from "node:crypto"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; export type VolcengineTtsEncoding = "ogg_opus" | "mp3" | "pcm" | "wav"; @@ -27,6 +28,7 @@ const DEFAULT_LEGACY_VOICE = "zh_female_xiaohe_uranus_bigtts"; const DEFAULT_CLUSTER = "volcano_tts"; const DEFAULT_SEED_TTS_RESOURCE_ID = "seed-tts-1.0"; const DEFAULT_SEED_TTS_APP_KEY = "aGjiRDfUWi"; +const VOLCENGINE_TTS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; const BYTEPLUS_SEED_TTS_URL = "https://voice.ap-southeast-1.bytepluses.com/api/v3/tts/unidirectional"; const VOLCENGINE_LEGACY_TTS_URL = "https://openspeech.bytedance.com/api/v1/tts"; @@ -158,7 +160,13 @@ async function seedSpeechTTS(params: VolcengineTTSParams & { apiKey: string }): }); try { - const frames = parseSeedTtsFrames(await response.text()); + const responseText = new TextDecoder().decode( + await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, { + onOverflow: ({ maxBytes }) => + new Error(`BytePlus Seed Speech TTS response exceeds ${maxBytes} bytes`), + }), + ); + const frames = parseSeedTtsFrames(responseText); const chunks: Buffer[] = []; for (const frame of frames) { if (frame.code === 0) { @@ -240,7 +248,13 @@ async function legacyVolcengineTTS( }); try { - const body = parseLegacyTtsResponse(await response.text()); + const responseText = new TextDecoder().decode( + await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, { + onOverflow: ({ maxBytes }) => + new Error(`Volcengine TTS response exceeds ${maxBytes} bytes`), + }), + ); + const body = parseLegacyTtsResponse(responseText); if (!response.ok || body.code !== 3000 || !body.data) { throw new Error( `Volcengine TTS error ${body.code ?? response.status}: ${body.message ?? "unknown"}`, diff --git a/extensions/xiaomi/speech-provider.test.ts b/extensions/xiaomi/speech-provider.test.ts index 992c5e4c4bcb..7c12e11bccf5 100644 --- a/extensions/xiaomi/speech-provider.test.ts +++ b/extensions/xiaomi/speech-provider.test.ts @@ -4,12 +4,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn()); +const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; + vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock, })); import { buildXiaomiSpeechProvider } from "./speech-provider.js"; +function makeOversizedStreamResponse(): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); +} + describe("buildXiaomiSpeechProvider", () => { const provider = buildXiaomiSpeechProvider(); @@ -410,5 +428,19 @@ describe("buildXiaomiSpeechProvider", () => { }), ).rejects.toThrow("Xiaomi TTS API returned no audio data"); }); + + it("bounds oversized Xiaomi TTS success response reads", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce(makeOversizedStreamResponse()); + + await expect( + provider.synthesize({ + text: "Test", + cfg: {} as never, + providerConfig: { apiKey: "sk-test" }, + target: "audio-file", + timeoutMs: 30000, + }), + ).rejects.toThrow("Xiaomi TTS API: JSON response exceeds 16777216 bytes"); + }); }); }); diff --git a/extensions/xiaomi/speech-provider.ts b/extensions/xiaomi/speech-provider.ts index 6ba61dbb8bf0..fdb9a468ca2f 100644 --- a/extensions/xiaomi/speech-provider.ts +++ b/extensions/xiaomi/speech-provider.ts @@ -1,7 +1,10 @@ // Xiaomi provider module implements model/runtime integration. import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; -import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowProviderError, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { SpeechDirectiveTokenParseContext, @@ -269,7 +272,8 @@ async function xiaomiTTS(params: { }); try { await assertOkOrThrowProviderError(response, "Xiaomi TTS API error"); - return decodeXiaomiAudioData(await response.json()); + const body = await readProviderJsonResponse(response, "Xiaomi TTS API"); + return decodeXiaomiAudioData(body); } finally { await release(); }