From 5c3ac6832882b9374845c855a56de45c4ff515ec Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 29 Sep 2026 00:09:41 -0400 Subject: [PATCH] feat: call page on its own origin, http://127.0.0.1:44548 (cinny #43) The bundled Element Call page ran on the app's own origin (http://localhost:44548), so the call frame could read the app's storage (login token) and DOM. Serve it from http://127.0.0.1:44548 instead: the same local server and bundle, a different origin. - The local server binds 127.0.0.1 explicitly. The app is still loaded as http://localhost:44548 (its storage stays where it is; the engines try 127.0.0.1 for `localhost`). Binding the name `localhost` could pick ::1 only (Windows lists it first), and then 127.0.0.1 wouldn't answer. - config.json: desktopCallOrigin = http://127.0.0.1:44548. cinny loads the call page from there only when this is set (cinny #43 PR). - CSP frame-src allows http://127.0.0.1:44548. - Permissions (on top of #22): the call page's origin gets microphone/ camera/screen only; nothing else. - The call page gets no IPC: the capability only matches http://localhost:44548. Tested (Linux release build): the server listens on 127.0.0.1:44548 and the app loads as http://localhost:44548; the call page loads from 127.0.0.1 inside the app under its CSP; from that frame parent.localStorage and parent.document are SecurityError, while a same-origin frame (the old setup) reads the app's storage. The call itself was tested in a simulated desktop (Chromium, the WebView2 engine) against a local Synapse + LiveKit; see the cinny PR. Rust tests 17 passed; Windows code type-checked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- config.json | 3 +- src-tauri/src/lib.rs | 12 +++++- src-tauri/src/webview_permissions.rs | 64 +++++++++++++++++++++++++--- src-tauri/tauri.conf.json | 2 +- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index d524158..58aaf48 100644 --- a/config.json +++ b/config.json @@ -24,5 +24,6 @@ "basename": "/" }, "gifApiKey": "", - "webAppUrl": "https://chat.lotusguild.org" + "webAppUrl": "https://chat.lotusguild.org", + "desktopCallOrigin": "http://127.0.0.1:44548" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77c2efc..c8de72a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -955,7 +955,17 @@ pub fn run() { native::hotkeys::global_hotkeys_supported, native::hotkeys::set_global_hotkeys, ]) - .plugin(tauri_plugin_localhost::Builder::new(port).build()) + // Bound to 127.0.0.1 explicitly (cinny #43). The app is still loaded as + // http://localhost:{port} (its storage lives under that origin, and the + // engines try 127.0.0.1 for `localhost`); the bundled call page is + // loaded as http://127.0.0.1:{port}, a separate origin on the same + // server. Binding the name `localhost` could pick ::1 only (Windows + // lists it first), and then the call page wouldn't load. + .plugin( + tauri_plugin_localhost::Builder::new(port) + .host("127.0.0.1") + .build(), + ) .plugin( // DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome) // owns the decorated flag. Letting window-state restore a saved diff --git a/src-tauri/src/webview_permissions.rs b/src-tauri/src/webview_permissions.rs index 8aaa39c..328e420 100644 --- a/src-tauri/src/webview_permissions.rs +++ b/src-tauri/src/webview_permissions.rs @@ -12,8 +12,13 @@ //! - WebKitGTK doesn't say which frame asked; the check is on the page loaded //! in the window. Frames are gated before the request gets this far by the //! Permissions Policy: cinny only puts `microphone; camera` in the `allow=` -//! of the call frame (same origin on desktop), and cross-origin frames get -//! neither location nor notifications. +//! of the call frame, and cross-origin frames get neither location nor +//! notifications. +//! +//! The call frame (cinny #43): the bundled Element Call page is loaded from +//! `http://127.0.0.1:{port}`, the same local server on a second origin, so it +//! can't reach the app's storage. WebView2 reports that origin for the call's +//! microphone/camera requests; it gets media and nothing else. use tauri::Url; @@ -57,6 +62,14 @@ pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) - if kind == Kind::Other { return Decision::Default; } + if app.is_call_frame(uri) { + // The call page: microphone/camera/screen only. + return if kind == Kind::Media && grants.contains(&kind) { + Decision::Allow + } else { + Decision::Deny + }; + } if !app.contains(uri) { return Decision::Deny; } @@ -76,9 +89,12 @@ fn origin_of(uri: &str) -> Option { Some((url.scheme().to_owned(), host, url.port_or_known_default())) } -/// The origins the app's own page is served from. +/// The origins the app's own page is served from, and the call page's. #[derive(Clone, Debug)] -pub(crate) struct AppOrigins(Vec); +pub(crate) struct AppOrigins { + app: Vec, + call: Option, +} impl AppOrigins { /// Release builds load `http://localhost:{port}` (tauri-plugin-localhost). @@ -93,11 +109,20 @@ impl AppOrigins { uris.push(dev.to_string()); } } - Self(uris.iter().filter_map(|u| origin_of(u)).collect()) + Self { + app: uris.iter().filter_map(|u| origin_of(u)).collect(), + call: origin_of(&format!("http://127.0.0.1:{port}/")), + } } + /// The app's own page. pub(crate) fn contains(&self, uri: &str) -> bool { - origin_of(uri).is_some_and(|o| self.0.contains(&o)) + origin_of(uri).is_some_and(|o| self.app.contains(&o)) + } + + /// The call page on its own origin (`http://127.0.0.1:{port}`). + pub(crate) fn is_call_frame(&self, uri: &str) -> bool { + origin_of(uri).is_some_and(|o| self.call.as_ref() == Some(&o)) } } @@ -204,6 +229,33 @@ mod tests { } } + #[test] + fn call_frame_gets_media_only() { + let app = app(); + let call = "http://127.0.0.1:44548/public/element-call/index.html?widgetId=x"; + assert!(app.is_call_frame(call)); + assert!(!app.contains(call)); + for grants in [LINUX_GRANTS, WINDOWS_GRANTS] { + assert_eq!(decide(Kind::Media, call, &app, grants), Decision::Allow); + for kind in [Kind::DeviceInfo, Kind::Notifications, Kind::Geolocation] { + assert_eq!(decide(kind, call, &app, grants), Decision::Deny, "{kind:?}"); + } + assert_eq!(decide(Kind::Other, call, &app, grants), Decision::Default); + } + for not_call in [ + "http://127.0.0.1:44549/", + "https://127.0.0.1:44548/", + "http://127.0.0.2:44548/", + "http://[::1]:44548/", + ] { + assert!(!app.is_call_frame(not_call), "{not_call}"); + assert_eq!( + decide(Kind::Media, not_call, &app, WINDOWS_GRANTS), + Decision::Deny + ); + } + } + #[test] fn other_kinds_are_left_to_the_engine() { let app = app(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0509432..7f094cb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -71,7 +71,7 @@ }, "app": { "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: http://127.0.0.1:44548 https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" } } } \ No newline at end of file