From e5fd30ccf01ac4a451ba3c8d08da2d6cd9158e22 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 18 Oct 2026 21:56:29 -0700 Subject: [PATCH] fix(ai): validate large base64 by decoded length, not regexps (#4265) * 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. --- .../gemini/GeminiTTSProvider.test.ts | 23 ++++++++ .../providers/gemini/GeminiTTSProvider.ts | 11 ++-- src/backend/util/appIcon.test.ts | Bin 18166 -> 18699 bytes src/backend/util/appIcon.ts | 2 - src/backend/util/mediaSniff.test.ts | 52 ++++++++++++++++++ src/backend/util/mediaSniff.ts | 26 ++++----- 6 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 src/backend/util/mediaSniff.test.ts 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..ed70d5dad 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts @@ -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()); 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); diff --git a/src/backend/util/mediaSniff.test.ts b/src/backend/util/mediaSniff.test.ts new file mode 100644 index 000000000..8e95f7dd7 --- /dev/null +++ b/src/backend/util/mediaSniff.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { decodeStrictBase64 } from './mediaSniff.ts'; + +describe('decodeStrictBase64', () => { + it.each([ + ['no padding', Buffer.from('abc')], + ['one pad character', Buffer.from('ab')], + ['two pad characters', Buffer.from('a')], + ])('decodes base64 with %s', (_label, bytes) => { + expect(decodeStrictBase64(bytes.toString('base64'))).toEqual(bytes); + }); + + it.each([ + ['empty input', ''], + ['unpadded input', 'YWI'], + ['the URL-safe alphabet', 'a-b_'], + ['whitespace', 'YW Jj'], + ['a character outside the alphabet', 'YW*j'], + ['a non-ASCII character whose low byte is in the alphabet', 'YWJī'], + ['padding mid-string', 'YQ==YWJj'], + ['three pad characters', 'Y==='], + ])('rejects %s', (_label, value) => { + expect(decodeStrictBase64(value)).toBeNull(); + }); + + it('rejects one bad character anywhere in a multi-MB payload', () => { + const valid = Buffer.alloc(6 * 1024 * 1024, 7).toString('base64'); + for (const at of [0, valid.length / 2, valid.length - 1]) { + const corrupt = `${valid.slice(0, at)}*${valid.slice(at + 1)}`; + expect(decodeStrictBase64(corrupt)).toBeNull(); + } + }); +}); diff --git a/src/backend/util/mediaSniff.ts b/src/backend/util/mediaSniff.ts index 24b53d71a..d0587eeab 100644 --- a/src/backend/util/mediaSniff.ts +++ b/src/backend/util/mediaSniff.ts @@ -20,8 +20,6 @@ // Identify uploaded media by its bytes. A caller-supplied MIME type or file // extension is never trusted. -const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; - /** * How far into a payload to look for an `