agent: code-atomiser-fix — main-window guard + size cap for clipboard surface (Trust-3, Trust-6)
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PasteOutcome, String> {
|
||||
pub async fn paste_text(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
text: String,
|
||||
) -> Result<PasteOutcome, String> {
|
||||
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<PasteOutcome, String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user