diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6669ad0..62e9416 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.0.66", + "version": "0.0.67", "description": "Calimero Desktop Application", "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6c431f2..22cd742 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -899,12 +899,33 @@ async fn create_app_window( // Configure IPC scope BEFORE showing window // This allows windows with unique labels (domain + timestamp) to access Tauri IPC - let remote_access = tauri::ipc::RemoteDomainAccessScope::new(domain) - .add_window(&window_label) - .enable_tauri_api(); - app_handle.ipc_scope().configure_remote_access(remote_access); + // + // Tauri v1 matches IPC scopes by EXACT domain (no wildcards), and the + // browser-style login flow navigates this same window to the node's auth + // page (e.g. http://localhost:2528/auth/login). Without a scope for the + // node's host, every IPC call from that page fails with + // "Scope not defined for URL". Register the app domain, the node host and + // localhost. Note: Url::domain() returns None for IP hosts (127.0.0.1), + // so scopes can never match IP-hosted pages — the injected proxy script + // falls back to native fetch there (plain-HTTP pages don't need the proxy). + let mut scope_domains: Vec = vec![domain.to_string(), "localhost".to_string()]; + if let Some(node_host) = node_url_to_use + .parse::() + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + { + scope_domains.push(node_host); + } + scope_domains.sort(); + scope_domains.dedup(); + for scope_domain in &scope_domains { + let remote_access = tauri::ipc::RemoteDomainAccessScope::new(scope_domain) + .add_window(&window_label) + .enable_tauri_api(); + app_handle.ipc_scope().configure_remote_access(remote_access); + } - info!("[Tauri] Configured IPC scope for domain: {} on window: {}", domain, window_label); + info!("[Tauri] Configured IPC scope for domains: {:?} on window: {}", scope_domains, window_label); // Camera/microphone for WebRTC video calls (e.g. Mero Meet) needs no extra // work here: wry's own WKUIDelegate already grants diff --git a/apps/desktop/src-tauri/src/proxy_script.js b/apps/desktop/src-tauri/src/proxy_script.js index a7115dd..933afe7 100644 --- a/apps/desktop/src-tauri/src/proxy_script.js +++ b/apps/desktop/src-tauri/src/proxy_script.js @@ -24,6 +24,24 @@ } } + // The proxy exists solely to bypass mixed-content blocking: HTTPS-hosted + // app pages cannot fetch http://localhost directly. When the page itself + // is served over plain HTTP (e.g. the node's auth frontend at + // http://localhost:2528/auth/login after a logout → browser-style login + // navigation), native fetch works fine — and Tauri v1 IPC scopes are + // exact-domain matches registered for the app's original domain, so + // invoking the proxy from a localhost-origin page fails with + // "Scope not defined for URL". Never proxy from non-HTTPS pages. + function pageNeedsProxy() { + try { + return window.location.protocol === 'https:'; + } catch (e) { + // No usable location (shouldn't happen in a real webview) — + // keep the historical always-proxy behavior. + return true; + } + } + // Is this the app's `POST /auth/refresh` call? // // Refresh tokens are single-use (calimero-network/core#3083): whoever calls @@ -278,9 +296,14 @@ // Proxy any HTTP localhost request (any port) to avoid mixed content blocking. // The Rust backend validates the URL before proxying. - // /auth/refresh is always routed through here — even for a non-localhost - // node — so the brokered sentinel can never reach the network. - const shouldProxy = isHttpLocalhost(urlStr) || isAuthRefresh(urlStr, effectiveMethod); + // Mixed-content proxying only applies to an HTTPS-hosted page (finding + // #145: proxying from a plain-HTTP localhost page fails the Tauri IPC + // scope check). But /auth/refresh is ALWAYS routed through here — even + // for a non-localhost node — so the brokered sentinel can never reach + // the network. + const shouldProxy = + (pageNeedsProxy() && isHttpLocalhost(urlStr)) || + isAuthRefresh(urlStr, effectiveMethod); console.log('[Tauri Proxy] Should proxy?', shouldProxy, 'for URL:', urlStr); if (shouldProxy) { @@ -364,13 +387,15 @@ headers: new Headers(response.headers) }); } catch (error) { - console.error('[Tauri Proxy] Fetch proxy failed:', error, 'URL:', urlStr); - const isTauri = typeof window.__TAURI_INVOKE__ === 'function' || - (typeof window.__TAURI__ !== 'undefined' && typeof window.__TAURI__.invoke === 'function'); - if (!isTauri) { - return originalFetch.apply(this, arguments); + if (error instanceof DOMException && error.name === 'AbortError') { + throw error; } - throw error; + // Last resort: try the native fetch. If the proxy failed for an + // IPC scope error while the page can reach localhost natively, + // this recovers; if mixed content blocks it, it fails the same + // way the proxy just did. + console.error('[Tauri Proxy] Fetch proxy failed, falling back to native fetch:', error, 'URL:', urlStr); + return originalFetch.apply(this, arguments); } } @@ -402,7 +427,9 @@ }; xhr.send = function(body) { - const shouldProxy = isHttpLocalhost(xhrUrl) || isAuthRefresh(xhrUrl, xhrMethod); + const shouldProxy = + (pageNeedsProxy() && isHttpLocalhost(xhrUrl)) || + isAuthRefresh(xhrUrl, xhrMethod); if (shouldProxy) { console.log('[Tauri Proxy] XHR intercepted:', xhrMethod, xhrUrl); @@ -438,12 +465,20 @@ xhr.dispatchEvent(new ProgressEvent('loadend')); }) .catch(function(error) { - console.error('[Tauri Proxy] XHR proxy failed:', error, 'URL:', xhrUrl); - if (typeof xhr.onerror === 'function') { - xhr.onerror(new ProgressEvent('error')); + // Same last-resort fallback as fetch: open() already ran + // on the real XHR, so a native send() can still succeed + // (e.g. when only the IPC scope was the problem). + console.error('[Tauri Proxy] XHR proxy failed, falling back to native XHR:', error, 'URL:', xhrUrl); + try { + originalSend(body); + } catch (sendError) { + console.error('[Tauri Proxy] Native XHR fallback failed:', sendError, 'URL:', xhrUrl); + if (typeof xhr.onerror === 'function') { + xhr.onerror(new ProgressEvent('error')); + } + xhr.dispatchEvent(new ProgressEvent('error')); + xhr.dispatchEvent(new ProgressEvent('loadend')); } - xhr.dispatchEvent(new ProgressEvent('error')); - xhr.dispatchEvent(new ProgressEvent('loadend')); }); return; } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a7faa92..f573604 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Calimero Desktop", - "version": "0.0.66" + "version": "0.0.67" }, "tauri": { "allowlist": { @@ -89,14 +89,7 @@ }, "security": { "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* http://127.0.0.1:* https://api.github.com; connect-src 'self' https://apps.calimero.network https://*.calimero.network http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss://* https: http: ws: wss: https://api.github.com https://github.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: http:; font-src 'self' data:; frame-src 'self' https: http:;", - "dangerousRemoteDomainIpcAccess": [ - { - "domain": "*", - "windows": ["*"], - "plugins": [], - "enableTauriAPI": true - } - ], + "dangerousRemoteDomainIpcAccess": [], "dangerousDisableAssetCspModification": true }, "systemTray": { diff --git a/apps/desktop/src/utils/proxyScript.test.ts b/apps/desktop/src/utils/proxyScript.test.ts index aab921c..091d998 100644 --- a/apps/desktop/src/utils/proxyScript.test.ts +++ b/apps/desktop/src/utils/proxyScript.test.ts @@ -30,7 +30,7 @@ function injectProxy(mockWindow: any, nodeUrl = "http://localhost:2428") { new Function("window", src)(mockWindow); } -function makeMockWindow() { +function makeMockWindow(pageProtocol = "https:") { // Minimal XMLHttpRequest stub. The script wraps open/send/setRequestHeader and // dispatches events on the instance, so those must exist for it to wrap them. function FakeXHR(this: any) { @@ -47,6 +47,9 @@ function makeMockWindow() { }; return { __TAURI_FETCH_PROXY_INJECTED__: undefined as boolean | undefined, + // The proxy only runs on HTTPS-hosted pages (mixed-content bypass); + // default the mock to an HTTPS page so proxy-path tests exercise it. + location: { protocol: pageProtocol }, // Stored by the script as `originalFetch` — returned for non-proxied URLs. fetch: vi.fn().mockResolvedValue(new Response("original", { status: 200 })), XMLHttpRequest: FakeXHR, @@ -148,6 +151,27 @@ describe("proxy_script SSE routing", () => { expect(mockWindow.__TAURI_INVOKE__).not.toHaveBeenCalled(); }); + it("does not invoke Tauri IPC when the page itself is plain HTTP (e.g. the node's auth page)", async () => { + // Pages served over http:// (like http://localhost:2528/auth/login after + // a logout → browser-style login) have no mixed-content problem, and the + // window's IPC scope may not cover their origin — native fetch must be + // used instead of the proxy. + const mockWindow = makeMockWindow("http:"); + mockWindow.__TAURI_INVOKE__ = vi.fn(); + mockWindow.__TAURI__ = { + event: { listen: vi.fn(async () => vi.fn()) }, + }; + + injectProxy(mockWindow); + + const response = await mockWindow.fetch( + "http://localhost:2528/auth/providers", + { headers: { Accept: "application/json" } } + ); + expect(mockWindow.__TAURI_INVOKE__).not.toHaveBeenCalled(); + expect(await response.text()).toBe("original"); + }); + it("does not invoke Tauri IPC for non-SSE localhost requests", async () => { const mockWindow = makeMockWindow(); const tauriInvoke = vi.fn().mockResolvedValue({ diff --git a/merod-config.json b/merod-config.json index 4cd7416..9ecca21 100644 --- a/merod-config.json +++ b/merod-config.json @@ -1,4 +1,4 @@ { "name": "merod binary", - "version": "0.11.0-rc.13" + "version": "0.11.0-rc.14" } \ No newline at end of file