Skip to content

Commit 2f851ec

Browse files
authored
fix(speech): bound TTS response reads (#96874)
1 parent 70e0fd4 commit 2f851ec

4 files changed

Lines changed: 103 additions & 4 deletions

File tree

extensions/volcengine/tts.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
77
fetchWithSsrFGuardMock: vi.fn(),
88
}));
99

10+
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
11+
1012
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
1113
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
1214
}));
@@ -45,6 +47,18 @@ function clearTtsEnv() {
4547
delete process.env.VOLCENGINE_TTS_TOKEN;
4648
}
4749

50+
function makeOversizedStreamResponse(): Response {
51+
return new Response(
52+
new ReadableStream<Uint8Array>({
53+
start(controller) {
54+
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
55+
controller.enqueue(new Uint8Array(1));
56+
controller.close();
57+
},
58+
}),
59+
);
60+
}
61+
4862
function restoreOptionalEnv(key: string, value: string | undefined) {
4963
if (value === undefined) {
5064
delete process.env[key];
@@ -301,6 +315,23 @@ describe("volcengineTTS", () => {
301315
expect(release).toHaveBeenCalledTimes(1);
302316
});
303317

318+
it("bounds Seed Speech success response reads", async () => {
319+
const release = vi.fn();
320+
fetchWithSsrFGuardMock.mockResolvedValue({
321+
response: makeOversizedStreamResponse(),
322+
release,
323+
});
324+
325+
await expect(
326+
volcengineTTS({
327+
text: "hello",
328+
apiKey: "secret-api-key",
329+
timeoutMs: 1000,
330+
}),
331+
).rejects.toThrow("BytePlus Seed Speech TTS response exceeds 16777216 bytes");
332+
expect(release).toHaveBeenCalledTimes(1);
333+
});
334+
304335
it("reports provider errors without exposing credentials", async () => {
305336
const release = vi.fn();
306337
fetchWithSsrFGuardMock.mockResolvedValue({
@@ -327,4 +358,22 @@ describe("volcengineTTS", () => {
327358
expect((error as Error).message).not.toContain("secret-token");
328359
expect(release).toHaveBeenCalledTimes(1);
329360
});
361+
362+
it("bounds legacy Volcengine success response reads", async () => {
363+
const release = vi.fn();
364+
fetchWithSsrFGuardMock.mockResolvedValue({
365+
response: makeOversizedStreamResponse(),
366+
release,
367+
});
368+
369+
await expect(
370+
volcengineTTS({
371+
text: "hello",
372+
appId: "app-id",
373+
token: "secret-token",
374+
timeoutMs: 1000,
375+
}),
376+
).rejects.toThrow("Volcengine TTS response exceeds 16777216 bytes");
377+
expect(release).toHaveBeenCalledTimes(1);
378+
});
330379
});

extensions/volcengine/tts.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Volcengine plugin module implements tts behavior.
22
import * as crypto from "node:crypto";
3+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
34
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
45

56
export type VolcengineTtsEncoding = "ogg_opus" | "mp3" | "pcm" | "wav";
@@ -27,6 +28,7 @@ const DEFAULT_LEGACY_VOICE = "zh_female_xiaohe_uranus_bigtts";
2728
const DEFAULT_CLUSTER = "volcano_tts";
2829
const DEFAULT_SEED_TTS_RESOURCE_ID = "seed-tts-1.0";
2930
const DEFAULT_SEED_TTS_APP_KEY = "aGjiRDfUWi";
31+
const VOLCENGINE_TTS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
3032
const BYTEPLUS_SEED_TTS_URL =
3133
"https://voice.ap-southeast-1.bytepluses.com/api/v3/tts/unidirectional";
3234
const VOLCENGINE_LEGACY_TTS_URL = "https://openspeech.bytedance.com/api/v1/tts";
@@ -158,7 +160,13 @@ async function seedSpeechTTS(params: VolcengineTTSParams & { apiKey: string }):
158160
});
159161

160162
try {
161-
const frames = parseSeedTtsFrames(await response.text());
163+
const responseText = new TextDecoder().decode(
164+
await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, {
165+
onOverflow: ({ maxBytes }) =>
166+
new Error(`BytePlus Seed Speech TTS response exceeds ${maxBytes} bytes`),
167+
}),
168+
);
169+
const frames = parseSeedTtsFrames(responseText);
162170
const chunks: Buffer[] = [];
163171
for (const frame of frames) {
164172
if (frame.code === 0) {
@@ -240,7 +248,13 @@ async function legacyVolcengineTTS(
240248
});
241249

242250
try {
243-
const body = parseLegacyTtsResponse(await response.text());
251+
const responseText = new TextDecoder().decode(
252+
await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, {
253+
onOverflow: ({ maxBytes }) =>
254+
new Error(`Volcengine TTS response exceeds ${maxBytes} bytes`),
255+
}),
256+
);
257+
const body = parseLegacyTtsResponse(responseText);
244258
if (!response.ok || body.code !== 3000 || !body.data) {
245259
throw new Error(
246260
`Volcengine TTS error ${body.code ?? response.status}: ${body.message ?? "unknown"}`,

extensions/xiaomi/speech-provider.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44

55
const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn());
66

7+
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
8+
79
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
810
transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock,
911
}));
1012

1113
import { buildXiaomiSpeechProvider } from "./speech-provider.js";
1214

15+
function makeOversizedStreamResponse(): Response {
16+
return new Response(
17+
new ReadableStream<Uint8Array>({
18+
start(controller) {
19+
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
20+
controller.enqueue(new Uint8Array(1));
21+
controller.close();
22+
},
23+
}),
24+
{
25+
status: 200,
26+
headers: { "Content-Type": "application/json" },
27+
},
28+
);
29+
}
30+
1331
describe("buildXiaomiSpeechProvider", () => {
1432
const provider = buildXiaomiSpeechProvider();
1533

@@ -410,5 +428,19 @@ describe("buildXiaomiSpeechProvider", () => {
410428
}),
411429
).rejects.toThrow("Xiaomi TTS API returned no audio data");
412430
});
431+
432+
it("bounds oversized Xiaomi TTS success response reads", async () => {
433+
vi.mocked(globalThis.fetch).mockResolvedValueOnce(makeOversizedStreamResponse());
434+
435+
await expect(
436+
provider.synthesize({
437+
text: "Test",
438+
cfg: {} as never,
439+
providerConfig: { apiKey: "sk-test" },
440+
target: "audio-file",
441+
timeoutMs: 30000,
442+
}),
443+
).rejects.toThrow("Xiaomi TTS API: JSON response exceeds 16777216 bytes");
444+
});
413445
});
414446
});

extensions/xiaomi/speech-provider.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Xiaomi provider module implements model/runtime integration.
22
import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
33
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
4-
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
4+
import {
5+
assertOkOrThrowProviderError,
6+
readProviderJsonResponse,
7+
} from "openclaw/plugin-sdk/provider-http";
58
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
69
import type {
710
SpeechDirectiveTokenParseContext,
@@ -269,7 +272,8 @@ async function xiaomiTTS(params: {
269272
});
270273
try {
271274
await assertOkOrThrowProviderError(response, "Xiaomi TTS API error");
272-
return decodeXiaomiAudioData(await response.json());
275+
const body = await readProviderJsonResponse<unknown>(response, "Xiaomi TTS API");
276+
return decodeXiaomiAudioData(body);
273277
} finally {
274278
await release();
275279
}

0 commit comments

Comments
 (0)