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 + ); + } +}