fix(speech): bound TTS response reads (#96874)

(cherry picked from commit 2f851ecfe9)
This commit is contained in:
Alix-007
2026-06-28 22:57:25 +08:00
committed by Dallin Romney
parent b1d2bff30e
commit 286ca2feb7
4 changed files with 103 additions and 4 deletions
+49
View File
@@ -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<Uint8Array>({
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);
});
});
+16 -2
View File
@@ -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"}`,
+32
View File
@@ -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<Uint8Array>({
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");
});
});
});
+6 -2
View File
@@ -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<unknown>(response, "Xiaomi TTS API");
return decodeXiaomiAudioData(body);
} finally {
await release();
}