Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.0.66",
"version": "0.0.67",
"description": "Calimero Desktop Application",
"type": "module",
"scripts": {
Expand Down
31 changes: 26 additions & 5 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = vec![domain.to_string(), "localhost".to_string()];
if let Some(node_host) = node_url_to_use
.parse::<url::Url>()
.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
Expand Down
65 changes: 50 additions & 15 deletions apps/desktop/src-tauri/src/proxy_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 2 additions & 9 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"package": {
"productName": "Calimero Desktop",
"version": "0.0.66"
"version": "0.0.67"
},
"tauri": {
"allowlist": {
Expand Down Expand Up @@ -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": {
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/utils/proxyScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion merod-config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "merod binary",
"version": "0.11.0-rc.13"
"version": "0.11.0-rc.14"
}
Loading