fix(ai): validate large base64 by decoded length, not regexps (#4265)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s

* 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.

* perf(ai): validate base64 by decoded length instead of regexp passes

Unoptimized regexp scans dominated large payloads: 186 ms for max-length
TTS audio, 880 ms in decodeStrictBase64 for 30 MB of video. Buffer.from
skips characters outside the alphabet, so comparing the decoded length
with the one the input length predicts catches them for free.

decodeStrictBase64 now does that plus rejecting the URL-safe alphabet
(~10 ms for 30 MB), and TTS reuses it instead of its own check.
Non-canonical trailing bits now decode instead of being rejected.

* fix(ai): reject non-ASCII base64 that Node decodes by its low byte

Node reads code units above 0xFF by their low byte, so U+012B decodes
as '+' and keeps the decoded length the input predicts. Require ASCII
(~1-2 ms for 43M chars) before the length check.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-18 21:56:29 -07:00
1 parent 517efda38a
commit e5fd30ccf0
6 files changed
+91 -23

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({
@@ -37,6 +37,7 @@ import {
postGeminiJson,
geminiTokenCount as tokenCount,
} from '../../../util/geminiRest.js';
import { decodeStrictBase64 } from '../../../../util/mediaSniff.js';
import { isUpstreamTimeoutError } from '../../../util/upstreamErrors.js';
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
import type {
@@ -154,7 +155,6 @@ 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}$/;
const SPLIT_TEXT_HINT = 'Split the text into shorter requests.';
@@ -524,14 +524,11 @@ export class GeminiTTSProvider implements ITTSProvider {
contentType: string;
} {
const { data, mime_type: mimeType } = audio;
if (
typeof data !== 'string' ||
!BASE64_PATTERN.test(data) ||
typeof mimeType !== 'string'
) {
const bytes =
typeof data === 'string' ? decodeStrictBase64(data) : null;
if (!bytes || typeof mimeType !== 'string') {
throw upstreamFailure('Gemini TTS returned invalid audio data');
}
const bytes = Buffer.from(data, 'base64');
const [essence, ...params] = mimeType
.split(';')
.map((part) => part.trim().toLowerCase());