From 286ca2feb7e3f549bd4116ad1e2c9f70bf5a52ba Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Sun, 28 Jun 2026 22:57:25 +0800
Subject: [PATCH] fix(speech): bound TTS response reads (#96874)
(cherry picked from commit 2f851ecfe9df4486878305798775f7e8f04fdf92)
---
extensions/volcengine/tts.test.ts | 49 +++++++++++++++++++++++
extensions/volcengine/tts.ts | 18 ++++++++-
extensions/xiaomi/speech-provider.test.ts | 32 +++++++++++++++
extensions/xiaomi/speech-provider.ts | 8 +++-
4 files changed, 103 insertions(+), 4 deletions(-)
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();
}