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;
|
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]
|
#[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}"))?;
|
let mut clipboard = Clipboard::new().map_err(|e| format!("Clipboard init failed: {e}"))?;
|
||||||
clipboard
|
clipboard
|
||||||
.set_text(&text)
|
.set_text(&text)
|
||||||
.map_err(|e| format!("Clipboard write failed: {e}"))?;
|
.map_err(|e| format!("Clipboard write failed: {e}"))?;
|
||||||
Ok(())
|
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 serde::Serialize;
|
||||||
use tauri::Manager;
|
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
|
/// Window after the paste keystroke at which we restore the user's
|
||||||
/// prior clipboard content. 300 ms is enough for even a slow Wayland
|
/// prior clipboard content. 300 ms is enough for even a slow Wayland
|
||||||
/// compositor to have fully delivered the synthesised Ctrl+V to the
|
/// 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
|
/// 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.
|
/// target before dispatching. Matches OpenWhispr's PR #246 fix on GNOME.
|
||||||
#[tauri::command]
|
#[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 {
|
let mut outcome = PasteOutcome {
|
||||||
backend: None,
|
backend: None,
|
||||||
pasted: false,
|
pasted: false,
|
||||||
@@ -183,9 +204,18 @@ fn should_restore(current: Option<&str>, transcript: &str) -> bool {
|
|||||||
/// can surface identical partial-success messaging.
|
/// can surface identical partial-success messaging.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn paste_text_replacing(
|
pub async fn paste_text_replacing(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
text: String,
|
text: String,
|
||||||
) -> Result<PasteOutcome, 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 {
|
let mut outcome = PasteOutcome {
|
||||||
backend: None,
|
backend: None,
|
||||||
pasted: false,
|
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