fix(security+audit): strip latent RCE grants, opener allowlist, GDI leaks, CI hardening

From the deep-audit wave (reviewer-verified: capability identifiers valid, no
removed-crate references, GDI free ordering correct):

- Removed 8 never-registered plugins (clipboard-manager, fs, shell, http,
  process, os, dialog, global-shortcut) from Cargo.toml AND their capability
  grants (shell:allow-execute, unscoped fs writes, http:default, …) — verified
  the web never invokes any of them. A latent RCE-class surface is gone.
- on_new_window: only http/https/mailto reach the OS opener (file:///custom
  schemes previously bypassed the opener capability scope entirely).
- set_badge_count: freed hdc + hdc_screen on all three GDI error paths
  (leaked per badge update in a long-running tray app).
- 8s reveal failsafe gated by an AtomicBool: no longer re-shows a window the
  user closed to tray; page-load reveal now fires once only (logout reloads
  don't re-surface a tray-hidden window); recovery for a missed page-load
  event preserved.
- toast.rs: store pruned on Activated too + capped at 20 (was unbounded).
- Startup no longer panics when the bundled icon is missing (tray skipped
  gracefully); msSmartScreenProtection no longer disabled (throttling
  disables kept); rust-version corrected to 1.77.2.
- release.yml update-manifest: fails on empty signatures (was: could publish
  a manifest that traps Windows users in a failed-update loop); partial-
  failure window documented. Deleted the stale upstream tauri.yml workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:21:55 -04:00
parent b9cfe3356a
commit f883781c1f
7 changed files with 120 additions and 260 deletions
+2 -10
View File
@@ -2,14 +2,14 @@
[package]
name = "cinny"
version = "4.12.2"
version = "4.12.2" # CI patches src-tauri/tauri.conf.json at build time; that file is the source of truth for the shipped version.
description = "Yet another matrix client"
authors = ["Ajay Bura"]
license = "AGPL-3.0-only"
repository = "https://github.com/cinnyapp/cinny-desktop"
default-run = "cinny"
edition = "2021"
rust-version = "1.61"
rust-version = "1.77.2"
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -20,14 +20,7 @@ serde = { version = "1.0.193", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "wry", "tray-icon", "image-png"] }
tauri-plugin-localhost = "2"
tauri-plugin-window-state = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
tauri-plugin-http = "2"
tauri-plugin-process = "2"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
@@ -40,7 +33,6 @@ default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
-1
View File
@@ -10,7 +10,6 @@
],
"permissions": [
"updater:default",
"global-shortcut:default",
"deep-link:default"
]
}
-35
View File
@@ -12,15 +12,6 @@
],
"permissions": [
"core:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
"core:window:allow-create",
"core:window:allow-center",
"core:window:allow-request-user-attention",
@@ -54,35 +45,9 @@
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-start-dragging",
"core:webview:allow-print",
"shell:allow-execute",
"shell:allow-open",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"http:default",
"notification:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"os:allow-platform",
"os:allow-version",
"os:allow-os-type",
"os:allow-family",
"os:allow-arch",
"os:allow-exe-extension",
"os:allow-locale",
"os:allow-hostname",
"process:allow-restart",
"process:allow-exit",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:app:allow-app-show",
"core:app:allow-app-hide",
"clipboard-manager:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
+86 -44
View File
@@ -196,8 +196,14 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let hbm_color =
CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.map_err(|e| e.to_string())?;
match CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) {
Ok(bm) => bm,
Err(e) => {
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err(e.to_string());
}
};
// Zero-init so undrawn pixels are fully transparent (CreateDIBSection
// does not guarantee zeroed memory; garbage bytes cause a black square).
if !bits.is_null() {
@@ -263,6 +269,8 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
if hbm_mask.0 as usize == 0 {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err("CreateBitmap failed".to_string());
}
@@ -276,6 +284,8 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
let hicon = CreateIconIndirect(&icon_info).map_err(|e| {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
e.to_string()
})?;
@@ -437,51 +447,54 @@ pub fn run() {
.setup(move |app| {
// --- System tray: keeps Lotus Chat running in the background so
// notifications keep arriving after the window is closed-to-tray. ---
let base_icon = app.default_window_icon().cloned();
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?;
let tray = TrayIconBuilder::with_id("main-tray")
.icon(base_icon.clone().expect("bundled window icon"))
.tooltip("Lotus Chat")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
show_main(app);
// Degrade gracefully if the bundled icon is missing rather than
// panicking at startup (the tray simply isn't created).
if let Some(base_icon) = app.default_window_icon().cloned() {
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?;
let tray = TrayIconBuilder::with_id("main-tray")
.icon(base_icon.clone())
.tooltip("Lotus Chat")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
show_main(app);
}
}
}
}
})
.build(app)?;
})
.build(app)?;
// Keep the tray handle (and base icon pixels) in managed state so
// set_tray_unread can re-render the icon at runtime.
if let Some(img) = base_icon {
let base_rgba = img.rgba().to_vec();
let (width, height) = (img.width(), img.height());
// Keep the tray handle (and base icon pixels) in managed state so
// set_tray_unread can re-render the icon at runtime.
let base_rgba = base_icon.rgba().to_vec();
let (width, height) = (base_icon.width(), base_icon.height());
app.manage(TrayUnreadState {
tray,
base_rgba,
width,
height,
});
} else {
eprintln!("tray: no bundled window icon; skipping system tray setup");
}
#[cfg(debug_assertions)]
@@ -494,6 +507,12 @@ pub fn run() {
};
let app_handle = app.handle().clone();
// Tracks whether the window's visibility has already been decided:
// set true by the on_page_load reveal (window shown) and by the
// close-to-tray handler (window intentionally hidden). The 8s failsafe
// below only reveals the window if neither of those has happened.
let window_settled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let settled_page_load = window_settled.clone();
let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url)
.title("Lotus Chat")
// First-run defaults; tauri-plugin-window-state restores geometry
@@ -513,15 +532,30 @@ pub fn run() {
// appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
)
.on_page_load(|window, payload| {
.on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
let _ = window.show();
// Reveal only on the FIRST settle: later page loads (e.g. a
// logout reload) must not re-show a window the user has
// since closed to the tray.
if !settled_page_load.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = window.show();
}
}
})
.on_new_window(move |url, _features| {
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
// Only hand well-known web/mail schemes to the OS opener.
// Forwarding arbitrary schemes (file://, custom protocols)
// bypasses the opener capability scope and reaches the OS.
match url.scheme() {
"http" | "https" | "mailto" => {
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
}
other => {
eprintln!("opener: refusing to open URL with scheme '{other}'");
}
}
NewWindowResponse::Deny
})
.build()?;
@@ -529,19 +563,27 @@ pub fn run() {
// Close-to-tray: hide instead of exiting; the app is quit explicitly
// from the tray menu.
let window_for_close = window.clone();
let settled_close = window_settled.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
// Mark the window state as settled so the failsafe below can't
// re-show a window the user just closed to the tray.
settled_close.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = window_for_close.hide();
}
});
// Failsafe: never leave the window stuck hidden if the page-load
// event doesn't fire for some reason.
// reveal never fires. Skips if the window state was already settled
// (revealed on page load, or intentionally hidden to the tray).
let window_for_show = window.clone();
let settled_failsafe = window_settled.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(8));
let _ = window_for_show.show();
if !settled_failsafe.load(std::sync::atomic::Ordering::SeqCst) {
let _ = window_for_show.show();
}
});
// Deep links (matrix:): route both the cold-start case and the
+17 -2
View File
@@ -158,7 +158,15 @@ fn show_windows_toast(
let room_id_owned = room_id.map(|s| s.to_string());
let path_owned = path.map(|s| s.to_string());
let activated = TypedEventHandler::<ToastNotification, IInspectable>::new(
move |_sender, args| {
move |sender, args| {
// Activation means this toast is done; drop it from the keep-alive
// store now. A Dismissed event doesn't reliably fire for a toast the
// user activated, so pruning only on Dismissed would leak it.
if let Some(sender) = sender.as_ref() {
if let Ok(mut store) = toast_store().lock() {
store.retain(|t| t != sender);
}
}
let Some(args) = args.as_ref() else {
return Ok(());
};
@@ -204,9 +212,16 @@ fn show_windows_toast(
);
let _ = toast.Dismissed(&dismissed)?;
// Keep the toast (and its handlers) alive until dismissed.
// Keep the toast (and its handlers) alive until dismissed/activated.
if let Ok(mut store) = toast_store().lock() {
store.push(toast.clone());
// Hard cap: if some Dismissed/Activated events are missed, retain only
// the most recent 20 toasts (dropping the oldest) so the store can't
// grow unbounded for the app's lifetime.
let len = store.len();
if len > 20 {
store.drain(0..len - 20);
}
}
// No AUMID argument: relies on the process AppUserModelID (see module note).