Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 3e136d3729 fix: WebView permissions only for the app's own origin (#22)
Linux (WebKitGTK) allowed every permission request of every kind; Windows
(WebView2) auto-allowed mic/camera/notifications without checking who asked.

Now (src-tauri/src/webview_permissions.rs, unit-tested):
- Linux: microphone/camera/screen, device labels, notifications and location
  are granted when the page in the window is the app
  (http://localhost:44548; debug builds also the bundled/dev page).
  Everything else is denied (WebKitGTK has no prompt of its own). WebKitGTK
  doesn't say which frame asked; frames are gated earlier by the Permissions
  Policy (cinny gives microphone/camera only to the same-origin call frame).
- Windows: the same grants (minus location, which keeps WebView2's prompt),
  checked against the origin of the frame that asked (args.Uri()). Other
  origins are denied mic/camera/notifications/location; other kinds keep
  WebView2's default handling.
- Denials are logged ("webview: denied …").

Tested on Linux with a release build under Xvfb + PulseAudio (no WebDriver:
WebKit's automation mode bypasses the handler), before/after:
- app page: mic, device labels, location allowed (unchanged)
- same-origin call frame: mic allowed (unchanged)
- cross-origin frame without allow=: blocked before the handler (unchanged)
- foreign top-level page: mic, device labels, location now denied (were
  allowed)
Real cinny build: boots, logs in, no denials. Windows code type-checked
(x86_64-pc-windows-gnu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 20:03:11 -04:00
Lotus CI 11e102f7da chore: bump cinny submodule to be8e49a2
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m9s
Build Lotus Chat Desktop / build-arch (push) Successful in 12s
Build Lotus Chat Desktop / build-windows (push) Successful in 26m53s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-09-28 03:30:38 +00:00
jared 8ee90444ee Merge pull request #24: NVIDIA + Wayland keeps the GPU renderer
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m39s
Build Lotus Chat Desktop / build-arch (push) Successful in 13s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m24s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
2026-09-27 22:26:51 -04:00
3 changed files with 327 additions and 40 deletions
+1 -1
Submodule cinny updated: e2b23397bd...be8e49a2bb
+105 -39
View File
@@ -13,6 +13,8 @@ use tauri_plugin_opener::OpenerExt;
pub mod gpu_workarounds; pub mod gpu_workarounds;
mod native; mod native;
#[cfg(any(target_os = "linux", target_os = "windows", test))]
mod webview_permissions;
/// Bring the main window to the foreground from the tray / a hidden / /// Bring the main window to the foreground from the tray / a hidden /
/// minimized state. Shared by the tray, single-instance, and deep-link paths. /// minimized state. Shared by the tray, single-instance, and deep-link paths.
@@ -1221,41 +1223,76 @@ pub fn run() {
let _ = window_vibrancy::apply_mica(&window, Some(true)); let _ = window_vibrancy::apply_mica(&window, Some(true));
} }
// Auto-grant camera, microphone, and notification permissions in WebView2. // cinny-desktop #22: the app's own page gets the microphone, camera
#[cfg(target_os = "windows")] // and notifications without a prompt; other origins (room widgets,
window.with_webview(|webview| { // link-preview embeds) are refused them. See webview_permissions.
use webview2_com::{ #[cfg(any(target_os = "linux", target_os = "windows"))]
Microsoft::Web::WebView2::Win32::{ let app_origins = webview_permissions::AppOrigins::new(
COREWEBVIEW2_PERMISSION_KIND, port,
COREWEBVIEW2_PERMISSION_KIND_CAMERA, app.config().build.dev_url.as_ref(),
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, );
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
},
PermissionRequestedEventHandler,
};
let controller = webview.controller(); #[cfg(target_os = "windows")]
if let Ok(core) = unsafe { controller.CoreWebView2() } { window.with_webview({
let handler = PermissionRequestedEventHandler::create(Box::new( let app_origins = app_origins.clone();
|_sender, args| { move |webview| {
if let Some(args) = args { use webview2_com::{
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0); Microsoft::Web::WebView2::Win32::{
unsafe { args.PermissionKind(&mut kind) }?; COREWEBVIEW2_PERMISSION_KIND,
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE COREWEBVIEW2_PERMISSION_KIND_CAMERA,
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION,
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
{ COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
unsafe { COREWEBVIEW2_PERMISSION_STATE_ALLOW,
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) COREWEBVIEW2_PERMISSION_STATE_DENY,
}?;
}
}
Ok(())
}, },
)); PermissionRequestedEventHandler,
let mut token = Default::default(); };
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) }; use webview_permissions::{decide, Decision, Kind, WINDOWS_GRANTS};
let controller = webview.controller();
if let Ok(core) = unsafe { controller.CoreWebView2() } {
let handler = PermissionRequestedEventHandler::create(Box::new(
move |_sender, args| {
if let Some(args) = args {
let mut raw = COREWEBVIEW2_PERMISSION_KIND(0);
unsafe { args.PermissionKind(&mut raw) }?;
let kind = if raw == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|| raw == COREWEBVIEW2_PERMISSION_KIND_CAMERA
{
Kind::Media
} else if raw == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS {
Kind::Notifications
} else if raw == COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION {
Kind::Geolocation
} else {
Kind::Other
};
// The origin of the frame that asked.
let mut uri = windows::core::PWSTR::null();
unsafe { args.Uri(&mut uri) }?;
let uri = webview2_com::take_pwstr(uri);
match decide(kind, &uri, &app_origins, WINDOWS_GRANTS) {
Decision::Allow => unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
}?,
Decision::Deny => {
eprintln!(
"webview: denied {kind:?} permission to {uri}"
);
unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)
}?
}
Decision::Default => {}
}
}
Ok(())
},
));
let mut token = Default::default();
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
}
} }
})?; })?;
@@ -1263,19 +1300,48 @@ pub fn run() {
// default (unlike WebView2/WKWebView), which leaves // default (unlike WebView2/WKWebView), which leaves
// `navigator.mediaDevices` undefined and makes Element Call // `navigator.mediaDevices` undefined and makes Element Call
// report "browser does not support WebRTC". Turn them on and // report "browser does not support WebRTC". Turn them on and
// auto-grant the resulting camera/mic permission prompt, mirroring // answer the permission requests, mirroring the WebView2 handling
// the WebView2 handling above. // above. WebKitGTK doesn't say which frame asked, so the origin
// checked is the page in the window (see webview_permissions).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
window.with_webview(|webview| { window.with_webview(move |webview| {
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; use webkit2gtk::glib::prelude::ObjectExt;
use webkit2gtk::{
DeviceInfoPermissionRequest, GeolocationPermissionRequest,
NotificationPermissionRequest, PermissionRequestExt, SettingsExt,
UserMediaPermissionRequest, WebViewExt,
};
use webview_permissions::{decide, Decision, Kind, LINUX_GRANTS};
let wv = webview.inner(); let wv = webview.inner();
if let Some(settings) = WebViewExt::settings(&wv) { if let Some(settings) = WebViewExt::settings(&wv) {
settings.set_enable_media_stream(true); settings.set_enable_media_stream(true);
settings.set_enable_webrtc(true); settings.set_enable_webrtc(true);
} }
wv.connect_permission_request(|_webview, request| { wv.connect_permission_request(move |wv, request| {
request.allow(); let kind = if request.is::<UserMediaPermissionRequest>() {
Kind::Media
} else if request.is::<DeviceInfoPermissionRequest>() {
Kind::DeviceInfo
} else if request.is::<NotificationPermissionRequest>() {
Kind::Notifications
} else if request.is::<GeolocationPermissionRequest>() {
Kind::Geolocation
} else {
Kind::Other
};
let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default();
match decide(kind, &uri, &app_origins, LINUX_GRANTS) {
Decision::Allow => request.allow(),
// No prompt of our own: anything not granted is denied.
Decision::Deny | Decision::Default => {
eprintln!(
"webview: denied {} to {uri}",
request.type_().name()
);
request.deny();
}
}
true true
}); });
})?; })?;
+221
View File
@@ -0,0 +1,221 @@
//! Which WebView permission requests the app grants (cinny-desktop #22).
//!
//! The web client asks for the microphone/camera/screen (calls, voice
//! messages), the device list (audio-output picker), notifications and the
//! location (location sharing). Those are granted without a prompt, but only
//! to the app's own origin. Everything else is left alone (Windows: WebView2's
//! own prompt) or denied (Linux: WebKitGTK has no prompt of its own).
//!
//! What "the requesting origin" means differs per engine:
//! - WebView2 reports the origin of the frame that asked (`args.Uri()`), so a
//! room widget or link-preview embed is refused here.
//! - 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.
use tauri::Url;
/// A permission request, reduced to what the policy cares about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
/// Microphone, camera or screen capture (getUserMedia/getDisplayMedia).
Media,
/// Device labels/ids from enumerateDevices (WebKitGTK only).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
DeviceInfo,
Notifications,
Geolocation,
/// Anything else: clipboard read, storage access, pointer lock, DRM, …
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Decision {
Allow,
Deny,
/// Let the engine decide (WebView2 prompts; WebKitGTK denies).
Default,
}
/// What the Linux (WebKitGTK) handler grants to the app.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) const LINUX_GRANTS: &[Kind] = &[
Kind::Media,
Kind::DeviceInfo,
Kind::Notifications,
Kind::Geolocation,
];
/// What the Windows (WebView2) handler grants to the app. Location keeps
/// WebView2's own prompt, as before.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) const WINDOWS_GRANTS: &[Kind] = &[Kind::Media, Kind::Notifications];
/// The decision for a request of `kind` from `uri`.
pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) -> Decision {
if kind == Kind::Other {
return Decision::Default;
}
if !app.contains(uri) {
return Decision::Deny;
}
if grants.contains(&kind) {
Decision::Allow
} else {
Decision::Default
}
}
/// scheme, host, port (explicit or the scheme's default).
type Origin = (String, String, Option<u16>);
fn origin_of(uri: &str) -> Option<Origin> {
let url = Url::parse(uri).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
Some((url.scheme().to_owned(), host, url.port_or_known_default()))
}
/// The origins the app's own page is served from.
#[derive(Clone, Debug)]
pub(crate) struct AppOrigins(Vec<Origin>);
impl AppOrigins {
/// Release builds load `http://localhost:{port}` (tauri-plugin-localhost).
/// Debug builds load the bundled page (`tauri://localhost`, or
/// `http://tauri.localhost` on Windows) or, under `tauri dev`, `dev_url`.
pub(crate) fn new(port: u16, dev_url: Option<&Url>) -> Self {
let mut uris = vec![format!("http://localhost:{port}/")];
if cfg!(debug_assertions) {
uris.push("tauri://localhost/".into());
uris.push("http://tauri.localhost/".into());
if let Some(dev) = dev_url {
uris.push(dev.to_string());
}
}
Self(uris.iter().filter_map(|u| origin_of(u)).collect())
}
pub(crate) fn contains(&self, uri: &str) -> bool {
origin_of(uri).is_some_and(|o| self.0.contains(&o))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> AppOrigins {
AppOrigins::new(44548, None)
}
#[test]
fn app_origin_matches_only_the_app() {
let app = app();
assert!(app.contains("http://localhost:44548/"));
assert!(app.contains("http://localhost:44548/#/home/!room:server"));
assert!(app.contains("http://LOCALHOST:44548/public/element-call/index.html"));
for other in [
"http://localhost:44549/",
"https://localhost:44548/",
"http://127.0.0.1:44548/",
"http://localhost/",
"http://localhost.evil.example:44548/",
"http://evil.example/?http://localhost:44548/",
"https://www.youtube-nocookie.com/embed/x",
"https://chat.lotusguild.org/",
"about:blank",
"data:text/html,hi",
"null",
"",
] {
assert!(!app.contains(other), "{other}");
}
}
#[test]
fn debug_builds_also_accept_the_bundled_and_dev_pages() {
let dev = Url::parse("http://localhost:8080").unwrap();
let app = AppOrigins::new(44548, Some(&dev));
assert_eq!(
app.contains("tauri://localhost/index.html"),
cfg!(debug_assertions)
);
assert_eq!(
app.contains("http://tauri.localhost/"),
cfg!(debug_assertions)
);
assert_eq!(
app.contains("http://localhost:8080/"),
cfg!(debug_assertions)
);
assert!(app.contains("http://localhost:44548/"));
assert!(!app.contains("tauri://evil/"));
}
#[test]
fn app_gets_its_grants_without_a_prompt() {
let app = app();
let uri = "http://localhost:44548/";
for kind in [
Kind::Media,
Kind::DeviceInfo,
Kind::Notifications,
Kind::Geolocation,
] {
assert_eq!(
decide(kind, uri, &app, LINUX_GRANTS),
Decision::Allow,
"{kind:?}"
);
}
assert_eq!(
decide(Kind::Media, uri, &app, WINDOWS_GRANTS),
Decision::Allow
);
assert_eq!(
decide(Kind::Notifications, uri, &app, WINDOWS_GRANTS),
Decision::Allow
);
// Location on Windows keeps WebView2's prompt.
assert_eq!(
decide(Kind::Geolocation, uri, &app, WINDOWS_GRANTS),
Decision::Default
);
}
#[test]
fn other_origins_are_refused() {
let app = app();
for uri in [
"https://widget.example/",
"https://www.youtube-nocookie.com/embed/x",
"",
] {
for kind in [
Kind::Media,
Kind::DeviceInfo,
Kind::Notifications,
Kind::Geolocation,
] {
assert_eq!(decide(kind, uri, &app, LINUX_GRANTS), Decision::Deny);
assert_eq!(decide(kind, uri, &app, WINDOWS_GRANTS), Decision::Deny);
}
}
}
#[test]
fn other_kinds_are_left_to_the_engine() {
let app = app();
for uri in ["http://localhost:44548/", "https://widget.example/"] {
assert_eq!(
decide(Kind::Other, uri, &app, LINUX_GRANTS),
Decision::Default
);
assert_eq!(
decide(Kind::Other, uri, &app, WINDOWS_GRANTS),
Decision::Default
);
}
}
}