From 7aee5348bcb521c88894f91b590a37b425cca572 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 13 May 2026 18:01:02 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20code-atomiser-fix=20=E2=80=94=20main-w?= =?UTF-8?q?indow=20guard=20+=20size=20cap=20for=20clipboard=20surface=20(T?= =?UTF-8?q?rust-3,=20Trust-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paste_text`, `paste_text_replacing`, and `copy_to_clipboard` previously exposed asymmetric trust against the rest of the Tauri command surface: no `ensure_main_window` guard and no payload-size cap. A compromised webview could synthesise an arbitrary Ctrl+V into the foreground application or write multi-megabyte payloads into the system clipboard without restriction. `paste_text*` is particularly hot because it also synthesises keystrokes into whatever app currently has focus. This change: - adds `ensure_main_window(&window)?` to all three commands. Each now takes a `tauri::WebviewWindow` parameter that Tauri injects automatically — frontend invoke call sites are unchanged in their TypeScript signatures and `npm run check` is green; - introduces a shared 1 MiB cap (`MAX_CLIPBOARD_BYTES` / `MAX_PASTE_BYTES`) that both surfaces enforce identically. Drift between the two caps would let an attacker copy a >1 MiB payload via one command and paste it via the other; a unit test asserts the constants stay in lock-step. Tests added: - `commands::clipboard::tests` — accepts normal payload, accepts exactly-at-cap, rejects above-cap. - `commands::paste::tests_paste_size_cap` — accepts typical dictation payload, rejects above-cap, asserts paste cap matches clipboard cap. Note: `copy_to_clipboard` is currently invoked from the preview (`/preview`) and viewer (`/viewer`) routes (HistoryPage and DictationPage too, but those run in the main window). After this change the preview and viewer invocations will surface a "main window only" error at runtime. `npm run check` cannot catch this — flagged for follow-up; the fix is to refactor those routes to delegate the copy through the main window via an event. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/clipboard.rs | 65 +++++++++++++++++++++++- src-tauri/src/commands/paste.rs | 77 ++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs index 1db9a6a..8489559 100644 --- a/src-tauri/src/commands/clipboard.rs +++ b/src-tauri/src/commands/clipboard.rs @@ -1,11 +1,72 @@ use arboard::Clipboard; -/// Copy text to the system clipboard via arboard. +use crate::commands::security::ensure_main_window; + +/// Refuse-to-set-clipboard limit. 1 MiB is comfortably above any +/// dictation output and any sensible "copy raw transcript" — anything +/// larger is a memory-pressure footgun if the clipboard backend +/// (arboard → X11 INCR / Wayland data device / NSPasteboard) tries to +/// materialise the full string for every paste target. The same cap is +/// used by `commands::paste::paste_text` so the two surfaces refuse +/// identically. +pub(crate) const MAX_CLIPBOARD_BYTES: usize = 1024 * 1024; + +/// Copy text to the system clipboard via arboard. Restricted to the +/// main window and capped at `MAX_CLIPBOARD_BYTES` to keep the IPC +/// surface symmetric with the rest of the codebase (Trust-6, +/// 2026-05-12 code-atomiser fix). #[tauri::command] -pub fn copy_to_clipboard(text: String) -> Result<(), String> { +pub fn copy_to_clipboard(window: tauri::WebviewWindow, text: String) -> Result<(), String> { + ensure_main_window(&window)?; + if text.len() > MAX_CLIPBOARD_BYTES { + return Err(format!( + "Clipboard payload too large ({} bytes; limit {} bytes).", + text.len(), + MAX_CLIPBOARD_BYTES + )); + } let mut clipboard = Clipboard::new().map_err(|e| format!("Clipboard init failed: {e}"))?; clipboard .set_text(&text) .map_err(|e| format!("Clipboard write failed: {e}"))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Pure helper that mirrors the size-cap branch in + /// `copy_to_clipboard`, factored so we can test it without a real + /// `tauri::WebviewWindow`. The command itself wires this into + /// `ensure_main_window` + arboard. + fn size_check(text: &str) -> Result<(), String> { + if text.len() > MAX_CLIPBOARD_BYTES { + Err(format!( + "Clipboard payload too large ({} bytes; limit {} bytes).", + text.len(), + MAX_CLIPBOARD_BYTES + )) + } else { + Ok(()) + } + } + + #[test] + fn accepts_normal_payload() { + assert!(size_check("hello world").is_ok()); + } + + #[test] + fn rejects_payload_above_cap() { + let big = "a".repeat(MAX_CLIPBOARD_BYTES + 1); + let err = size_check(&big).expect_err("expected size-cap rejection"); + assert!(err.contains("too large"), "unexpected error: {err}"); + } + + #[test] + fn accepts_payload_exactly_at_cap() { + let exact = "b".repeat(MAX_CLIPBOARD_BYTES); + assert!(size_check(&exact).is_ok()); + } +} diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 2657ba7..7c46e9e 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -21,6 +21,15 @@ use arboard::Clipboard; use serde::Serialize; use tauri::Manager; +use crate::commands::security::ensure_main_window; + +/// Refuse-to-paste limit. Matches `commands::clipboard::MAX_CLIPBOARD_BYTES` +/// (1 MiB) so paste and copy surfaces share a single rejection rule. +/// Synthesising a 50 MiB Ctrl+V keystroke into the foreground app would +/// also be a denial-of-service against the target (LibreOffice / Notes +/// can hang or crash on multi-megabyte paste payloads). +pub(crate) const MAX_PASTE_BYTES: usize = crate::commands::clipboard::MAX_CLIPBOARD_BYTES; + /// Window after the paste keystroke at which we restore the user's /// prior clipboard content. 300 ms is enough for even a slow Wayland /// compositor to have fully delivered the synthesised Ctrl+V to the @@ -64,7 +73,19 @@ pub struct PasteOutcome { /// the preview window and give the compositor a beat to re-focus the real /// target before dispatching. Matches OpenWhispr's PR #246 fix on GNOME. #[tauri::command] -pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result { +pub async fn paste_text( + window: tauri::WebviewWindow, + app: tauri::AppHandle, + text: String, +) -> Result { + ensure_main_window(&window)?; + if text.len() > MAX_PASTE_BYTES { + return Err(format!( + "Paste payload too large ({} bytes; limit {} bytes).", + text.len(), + MAX_PASTE_BYTES + )); + } let mut outcome = PasteOutcome { backend: None, pasted: false, @@ -183,9 +204,18 @@ fn should_restore(current: Option<&str>, transcript: &str) -> bool { /// can surface identical partial-success messaging. #[tauri::command] pub async fn paste_text_replacing( + window: tauri::WebviewWindow, app: tauri::AppHandle, text: String, ) -> Result { + ensure_main_window(&window)?; + if text.len() > MAX_PASTE_BYTES { + return Err(format!( + "Paste payload too large ({} bytes; limit {} bytes).", + text.len(), + MAX_PASTE_BYTES + )); + } let mut outcome = PasteOutcome { backend: None, pasted: false, @@ -788,3 +818,48 @@ mod tests_terminal_classification { ); } } + +#[cfg(test)] +mod tests_paste_size_cap { + use super::MAX_PASTE_BYTES; + + /// Pure mirror of the size-cap branch in `paste_text` / + /// `paste_text_replacing`. Wiring is tested through the command + /// surface; this asserts the rule itself. + fn size_check(text: &str) -> Result<(), String> { + if text.len() > MAX_PASTE_BYTES { + Err(format!( + "Paste payload too large ({} bytes; limit {} bytes).", + text.len(), + MAX_PASTE_BYTES + )) + } else { + Ok(()) + } + } + + #[test] + fn accepts_typical_dictation_payload() { + // ~500 chars of dictated text: comfortably under the cap. + let text = "a".repeat(500); + assert!(size_check(&text).is_ok()); + } + + #[test] + fn rejects_payload_above_cap() { + let big = "x".repeat(MAX_PASTE_BYTES + 1); + let err = size_check(&big).expect_err("size-cap should reject"); + assert!(err.contains("too large")); + } + + #[test] + fn paste_cap_matches_clipboard_cap() { + // Both surfaces must refuse identically — drift between the + // two would let an attacker copy a 1 MiB-plus payload via one + // command and synthesise it via the other. + assert_eq!( + MAX_PASTE_BYTES, + crate::commands::clipboard::MAX_CLIPBOARD_BYTES + ); + } +}