mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 05:24:13 +00:00
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
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:
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());
|
||||
|
||||
Reference in new issue
Block a user