From 80d36cd2511a92af00dbfc3c66516e04c033b666 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 18 Oct 2026 21:31:59 -0700 Subject: [PATCH] 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. --- .../gemini/GeminiTTSProvider.test.ts | 23 ++++++++++++++++++ .../providers/gemini/GeminiTTSProvider.ts | 14 +++++++++-- src/backend/util/appIcon.test.ts | Bin 18166 -> 18699 bytes src/backend/util/appIcon.ts | 2 -- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts index c3c6857e8..395104730 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts @@ -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({ diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts index 1d7295847..c4541aaa0 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts @@ -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'); diff --git a/src/backend/util/appIcon.test.ts b/src/backend/util/appIcon.test.ts index 86715c5c402733d7a775e05e18f19de5cc5bd342..aa9b877975056728589c675a99b605eb86702950 100644 GIT binary patch delta 461 zcmaiw&q~8U5XSKjB0Wjyy zn1Yx@J_grm*e;@3IJi%r&@h(u6+Ooe9Vk zjcHB-C=9b3cR5=sE@bPa!m!y3LzGqDz%p%Lo}C_7U@~KIOup^H4(#pjPh0t_TyBhE za^}>lD%7o*MXF^YYHMk*ZOd^4IX^KtlVHZ~)~~Nx-oZm*K3B/[;param[=value]]…,`. 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);