diff --git a/src/main/runtime/rpc/methods/speech.test.ts b/src/main/runtime/rpc/methods/speech.test.ts index db730ae27..4f31ee386 100644 --- a/src/main/runtime/rpc/methods/speech.test.ts +++ b/src/main/runtime/rpc/methods/speech.test.ts @@ -52,4 +52,23 @@ describe('speech RPC methods', () => { expect(response).toMatchObject({ ok: false }) expect(runtime.feedMobileDictation).not.toHaveBeenCalled() }) + + it('rejects oversized dictation chunks before decoding audio', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + feedMobileDictation: vi.fn().mockReturnValue({ dictationId: 'dict-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('speech.dictation.chunk', { + dictationId: 'dict-1', + audioBase64: 'A'.repeat(Math.ceil((16_000 * 2 * 5) / 3) * 4 + 1), + sampleRate: 16_000 + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.feedMobileDictation).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index 71a760125..8e1ceafd5 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -3,6 +3,12 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalString, requiredString } from '../schemas' const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ +const DICTATION_SAMPLE_RATE = 16_000 +const PCM_BYTES_PER_SAMPLE = 2 +const MAX_DICTATION_AUDIO_SECONDS = 5 +const MAX_DICTATION_AUDIO_CHUNK_BYTES = + DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS +const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 function isValidAudioBase64(value: string): boolean { return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) @@ -16,6 +22,12 @@ const DictationStart = z.object({ const DictationChunk = z.object({ dictationId: requiredString('Missing dictation ID'), audioBase64: requiredString('Missing audio chunk') + // Why: feedMobileDictation decodes into Buffer + Float32Array; reject + // oversized chunks before allocation. This mirrors the mobile pending-audio budget. + .refine( + (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, + 'Audio chunk is too large' + ) // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject // bad mobile audio chunks instead of feeding empty/corrupt PCM. .refine(isValidAudioBase64, 'Audio chunk must be base64'),