From e0cf1d98011d44d55c5c2f4d157ffd82f69f567f Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Tue, 7 Jul 2026 17:13:42 -0700 Subject: [PATCH] fix(desktop): renderer permissions, Origin rewrite scope, route state fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The permission request handler granted every renderer permission; its media check called callback(true) in both branches. Now only 'media' (dictation microphone) and 'clipboard-sanitized-write' (copy buttons) are granted; everything else is denied and logged. - onBeforeSendHeaders rewrote the Origin header to http://localhost:5173 on every outgoing request, including third-party hosts. goosed's origin policy only concerns loopback targets, so the rewrite is now scoped to them. - Route wrappers fell back to window.history.state for view options, but react-router stores user state under history.state.usr, so the fallback could only ever produce the router's wrapper object — and SettingsRoute then mutated it. Read location.state only, and build the settings view options instead of mutating. - ProgressiveMessageList marked the last *rendered* message as streaming, which targets the wrong message while progressive batches are still loading. Compare against the full message list instead. Co-Authored-By: Claude Fable 5 --- ui/desktop/src/App.tsx | 21 ++++-------- .../src/components/ProgressiveMessageList.tsx | 3 +- ui/desktop/src/main.ts | 33 +++++++++++++------ 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 37854fd2bd..79d55f3d9e 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -91,8 +91,7 @@ const PairRouteWrapper = ({ }) => { const { extensionsList } = useConfig(); const location = useLocation(); - const routeState = - (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; + const routeState = (location.state as PairRouteState) ?? {}; const [searchParams, setSearchParams] = useSearchParams(); const isCreatingSessionRef = useRef(false); const navigate = useNavigate(); @@ -185,15 +184,11 @@ const SettingsRoute = () => { const [searchParams] = useSearchParams(); const setView = useNavigation(); - // Get viewOptions from location.state, history.state, or URL search params - const viewOptions = - (location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {}; - - // If section is provided via URL search params, add it to viewOptions const sectionFromUrl = searchParams.get('section'); - if (sectionFromUrl) { - viewOptions.section = sectionFromUrl; - } + const viewOptions: SettingsViewOptions = { + ...((location.state as SettingsViewOptions) ?? {}), + ...(sectionFromUrl ? { section: sectionFromUrl } : {}), + }; return navigate('/')} setView={setView} viewOptions={viewOptions} />; }; @@ -272,11 +267,7 @@ const ExtensionsRoute = () => { const navigate = useNavigate(); const location = useLocation(); - // Get viewOptions from location.state or history.state (for deep link extensions) - const viewOptions = - (location.state as ExtensionsViewOptions) || - (window.history.state as ExtensionsViewOptions) || - {}; + const viewOptions = (location.state as ExtensionsViewOptions) ?? {}; return ( (); +function isLoopbackUrl(url: string): boolean { + try { + const { hostname } = new URL(url); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; + } catch { + return false; + } +} + function normalizeHostname(hostname: string): string { return hostname.toLowerCase(); } @@ -2415,16 +2424,15 @@ async function appMain() { registerUpdateIpcHandlers(); - // Handle microphone permission requests + // 'media' covers the dictation microphone; 'clipboard-sanitized-write' + // covers navigator.clipboard copy buttons. Everything else is denied. + const allowedRendererPermissions = new Set(['media', 'clipboard-sanitized-write']); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - console.log('Permission requested:', permission); - // Allow microphone and media access - if (permission === 'media') { - callback(true); - } else { - // Default behavior for other permissions - callback(true); + const allowed = allowedRendererPermissions.has(permission); + if (!allowed) { + log.warn(`Denied renderer permission request: ${permission}`); } + callback(allowed); }); // Add CSP headers to all sessions, recomputed on every response so external @@ -2451,9 +2459,14 @@ async function appMain() { // Register global shortcuts based on settings registerGlobalShortcuts(); + // goosed validates the Origin header of incoming requests. Rewrite it for + // loopback (goosed) targets only; other hosts must not receive a fabricated + // localhost origin. session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { - details.requestHeaders['Origin'] = 'http://localhost:5173'; - callback({ cancel: false, requestHeaders: details.requestHeaders }); + if (isLoopbackUrl(details.url)) { + details.requestHeaders['Origin'] = 'http://localhost:5173'; + } + callback({ requestHeaders: details.requestHeaders }); }); if (settings.showMenuBarIcon) {