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:
2026-05-13 18:01:02 +01:00
parent b3da58cd6b
commit 7aee5348bc
2 changed files with 139 additions and 3 deletions

View File

@@ -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());
}
}

View File

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