fix(ai): validate large base64 without anchored regexp loops

Gemini TTS replies over ~4 MB of base64 (~65 s of audio) failed with
"Maximum call stack size exceeded". Once a process holds enough compiled
code, V8 compiles new regexps unoptimized, and `^[...]+={0,2}$` then
pushes a backtrack entry per character until its stack overflows.

TTS now searches for a character outside the alphabet instead. The raw
base64 icon/reference-image check drops its own anchored regexp;
decodeStrictBase64 already rejects the same inputs.
This commit is contained in:
Daniel Salazar committed 2026-10-18 21:31:59 -07:00
1 parent 517efda38a
commit 80d36cd251
4 files changed
+35 -4

No files matched your search

@@ -28,6 +28,7 @@
*/
import { Readable } from 'node:stream';
import v8 from 'node:v8';
import {
afterAll,
afterEach,
@@ -660,6 +661,28 @@ describe('GeminiTTSProvider.synthesize audio output', () => {
expect(wav.subarray(44)).toEqual(pcm);
});
// V8 stops optimizing regexps once a process holds enough compiled code,
// as long-running servers do.
it('streams audio over 4 MB of base64 with regexp optimization off', async () => {
const pcm = Buffer.alloc(4 * 1024 * 1024, 7);
replyWith(
interaction({ content: [audioBlock(pcm, 'audio/L16;rate=24000')] }),
);
v8.setFlagsFromString('--no-regexp-optimization');
let wav: Buffer;
try {
const result = (await withTestActor(() =>
makeProvider().synthesize({ text: 'hi' }),
)) as { stream: Readable };
wav = await readAll(result.stream);
} finally {
v8.setFlagsFromString('--regexp-optimization');
}
expect(wav.length).toBe(44 + pcm.length);
expect(wav.subarray(44).equals(pcm)).toBe(true);
});
it('wraps PCM without a declared rate at 24 kHz', async () => {
replyWith(
interaction({
@@ -154,7 +154,17 @@ interface Interaction {
const WAV_MIME_TYPES = new Set(['audio/wav', 'audio/x-wav', 'audio/wave']);
const PCM_MIME_TYPES = new Set(['audio/l16', 'audio/pcm']);
const BASE64_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/;
// Find a bad character instead of matching `^[...]+$`: when V8 compiles a
// regexp unoptimized, the anchored loop backtracks per character and overflows
// its stack on audio over ~4 MB.
const NON_BASE64_CHAR = /[^A-Za-z0-9+/_-]/;
const isBase64 = (data: string): boolean => {
let end = data.length;
if (data.endsWith('==')) end -= 2;
else if (data.endsWith('=')) end -= 1;
return end > 0 && !NON_BASE64_CHAR.test(data.slice(0, end));
};
const SPLIT_TEXT_HINT = 'Split the text into shorter requests.';
@@ -526,7 +536,7 @@ export class GeminiTTSProvider implements ITTSProvider {
const { data, mime_type: mimeType } = audio;
if (
typeof data !== 'string' ||
!BASE64_PATTERN.test(data) ||
!isBase64(data) ||
typeof mimeType !== 'string'
) {
throw upstreamFailure('Gemini TTS returned invalid audio data');
Binary file not shown.
-2
View File
@@ -71,7 +71,6 @@ export interface AppIconHostConfig extends TrustedIconHostConfig {
pub_port?: number;
}
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
// `data:<type>/<subtype>[;param[=value]]…,<payload>`. The parameter list is
// matched as a group of its own so it can be checked exhaustively — the
// previous prefix-scan only looked at the bytes before the first `;` or `,`
@@ -185,7 +184,6 @@ function sniffRawBase64Image(
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (trimmed.length < 16) return null;
if (!RAW_BASE64_REGEX.test(trimmed)) return null;
const bytes = decodeStrictBase64(trimmed);
if (!bytes) return null;
const mime = sniffImageMime(bytes);