From 43c0cca96ecd4439e801be7654dfb8be221a15c9 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:52:54 +0300 Subject: [PATCH] fix(voice): validate config and request boundaries Malformed stored settings could break voice requests instead of using safe defaults. Health results could outlive auth changes. URL checks also did not guard the fetch sink. Remove constant recorder branches so lifecycle cancellation stays clear. --- server/voice-proxy.js | 6 +++++- src/components/chat/hooks/useVoiceAvailable.ts | 6 ------ src/components/chat/hooks/useVoiceInput.ts | 10 ++++------ src/hooks/useVoiceConfig.ts | 7 ++++++- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/server/voice-proxy.js b/server/voice-proxy.js index 304535a2..149459fb 100644 --- a/server/voice-proxy.js +++ b/server/voice-proxy.js @@ -56,10 +56,14 @@ const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0 * @returns {Promise} */ async function fetchWithTimeout(url, options = {}) { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol) || !isAllowedBackendUrl(parsed.origin)) { + throw new Error('Blocked outbound voice backend URL'); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS); try { - return await fetch(url, { redirect: 'manual', ...options, signal: controller.signal }); + return await fetch(parsed.toString(), { redirect: 'manual', ...options, signal: controller.signal }); } finally { clearTimeout(timer); } diff --git a/src/components/chat/hooks/useVoiceAvailable.ts b/src/components/chat/hooks/useVoiceAvailable.ts index df5fe533..0adccd0d 100644 --- a/src/components/chat/hooks/useVoiceAvailable.ts +++ b/src/components/chat/hooks/useVoiceAvailable.ts @@ -7,13 +7,11 @@ import { VOICE_CONFIG_SYNC_EVENT, voiceConfigHeaders } from '../../../hooks/useV // the Settings modal) and a configured voice backend. const STORAGE_KEY = 'uiPreferences'; const SYNC_EVENT = 'ui-preferences:sync'; -const healthCache = new Map(); const healthRequests = new Map>(); function checkVoiceHealth(): Promise { const baseUrl = voiceConfigHeaders()['x-voice-base-url']; const signature = baseUrl || ''; - if (healthCache.has(signature)) return Promise.resolve(healthCache.get(signature) ?? false); const pending = healthRequests.get(signature); if (pending) return pending; const request = authenticatedFetch('/api/voice/health', { @@ -24,10 +22,6 @@ function checkVoiceHealth(): Promise { const data = await response.json(); return data?.configured === true; }) - .then((available) => { - healthCache.set(signature, available); - return available; - }) .finally(() => { healthRequests.delete(signature); }); diff --git a/src/components/chat/hooks/useVoiceInput.ts b/src/components/chat/hooks/useVoiceInput.ts index 706fd6c4..400612a0 100644 --- a/src/components/chat/hooks/useVoiceInput.ts +++ b/src/components/chat/hooks/useVoiceInput.ts @@ -63,7 +63,6 @@ export function useVoiceInput( const start = useCallback(async () => { if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return; startingRef.current = true; - let recordingCancelled = false; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, @@ -84,7 +83,7 @@ export function useVoiceInput( rec.onstop = async () => { stopTracks(); - if (recordingCancelled || cancelledRef.current) return; + if (cancelledRef.current) return; // Capture and clear the send intent for this stop before any async work. const shouldSend = sendRef.current; sendRef.current = false; @@ -107,23 +106,22 @@ export function useVoiceInput( }); if (!res.ok) throw new Error(`transcribe ${res.status}`); const data = await res.json(); - if (recordingCancelled || cancelledRef.current) return; + if (cancelledRef.current) return; const text = String(data?.text || '').trim(); if (text) onTranscript(text, shouldSend); else onError?.('No speech detected'); } catch (e) { - if (!recordingCancelled && !cancelledRef.current) { + if (!cancelledRef.current) { onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`); } } finally { - if (!recordingCancelled && !cancelledRef.current) setState('idle'); + if (!cancelledRef.current) setState('idle'); } }; rec.start(); setState('recording'); } catch (e) { - recordingCancelled = true; recorderRef.current = null; stopTracks(); if (cancelledRef.current) return; diff --git a/src/hooks/useVoiceConfig.ts b/src/hooks/useVoiceConfig.ts index 77e22546..c9141f45 100644 --- a/src/hooks/useVoiceConfig.ts +++ b/src/hooks/useVoiceConfig.ts @@ -18,7 +18,12 @@ function read(): VoiceConfig { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULTS }; const parsed = JSON.parse(raw); - return { ...DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : {}) }; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...DEFAULTS }; + const config = { ...DEFAULTS }; + for (const key of Object.keys(DEFAULTS) as (keyof VoiceConfig)[]) { + if (typeof parsed[key] === 'string') config[key] = parsed[key]; + } + return config; } catch { return { ...DEFAULTS }; }