fix(ai): validate large base64 by decoded length, not regexps (#4265)
Backend Tests / changes (push) Waiting to run
Maintain Release Merge PR / update-release-pr (push) Waiting to run
Notify HeyPuter / notify (push) Waiting to run
release-please / release-please (push) Waiting to run

* 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());
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);
+52
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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();
}
});
});
+12 -14
View File
@@ -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 `<svg` root; SVG has no fixed magic
* number.
@@ -54,21 +52,21 @@ const MP4_BRANDS = new Set([
]);
/**
* Decode base64 that round-trips byte for byte. `Buffer.from(s, 'base64')`
* silently skips characters outside the alphabet; the round-trip rejects them.
* Decode padded, standard-alphabet base64 (the form data URLs accept), or null.
* `Buffer.from` skips characters outside the alphabet, so any of them leaves
* the output shorter than the length predicts. Avoids regexps, which cost a
* full extra pass over multi-MB payloads.
*/
export function decodeStrictBase64(value: string): Buffer | null {
if (!BASE64_CHARS_REGEX.test(value)) return null;
if (value.length === 0 || value.length % 4 !== 0) return null;
try {
const decoded = Buffer.from(value, 'base64');
if (decoded.length === 0) return null;
const stripped = value.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return stripped === reencoded ? decoded : null;
} catch {
return null;
}
// Node decodes a code unit above 0xFF by its low byte (`ī` as `+`), so
// only ASCII is decoded.
if (Buffer.byteLength(value, 'utf8') !== value.length) return null;
// Node also decodes the URL-safe alphabet.
if (value.includes('-') || value.includes('_')) return null;
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
const bytes = Buffer.from(value, 'base64');
return bytes.length === (value.length / 4) * 3 - padding ? bytes : null;
}
function looksLikeSvg(bytes: Buffer): boolean {