From 57dcff6d9adc967ff88aa6282c0491ca676555cf Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:39:35 -0400 Subject: [PATCH] M7: #1360 review: cap the clipboard PNG's bytes before copying it The pixel cap reads only the PNG's header, so a small image followed by a large tail of data passed it and was copied and base64-encoded whole. MAX_CLIPBOARD_PNG_BYTES (256 MB, the same cap as design data read from the clipboard) is checked on the NSData length before the copy on macOS, and again in the paste path so every Services implementation is held to it. A PNG past the cap is treated like an unreadable one: paste falls back to the decoded image. --- apps/figcraft/src/services.rs | 2 +- crates/ui-egui/src/lib.rs | 5 ++++- crates/ui-egui/src/menus/clipboard.rs | 3 ++- crates/ui-egui/src/paste_image_tests.rs | 14 ++++++++++++++ crates/ui-egui/src/services.rs | 2 ++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 44bb20c06..ca8810673 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -445,7 +445,7 @@ impl Services for Desktop { fn clipboard_get_png(&mut self) -> Option> { let board = objc2_app_kit::NSPasteboard::generalPasteboard(); let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?; - Some(png.to_vec()) + (png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec()) } fn open_url(&mut self, url: &str) { let _ = webbrowser::open(url); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1eb714e9a..8eba96f94 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,7 +284,10 @@ mod state; pub(crate) use clock::repaint_in; pub use clock::{AUTO_SAVE_SECS, AUTOSAVE_SECS, FrameClock, ToastClock}; pub(crate) use open::{drop_overlay, dropped_bytes, is_document, opens}; -pub use services::{ContentsFuture, DialogFuture, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text, unreadable}; +pub use services::{ + ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text, + unreadable, +}; use services::{DialogDone, PickFuture, Picked, RepaintWaker}; pub use state::{ AutoSaveEncoding, CANVAS_MIN, LEFT_PANEL, LeftTab, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels, diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index 5185e4a87..0af141763 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -101,7 +101,8 @@ fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) /// The system clipboard's image as PNG bytes, with its size. fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { - let (png, (w, h)) = match app.services.clipboard_get_png().and_then(with_pixel_size) { + let clipboard_png = app.services.clipboard_get_png().filter(|png| png.len() <= crate::MAX_CLIPBOARD_PNG_BYTES); + let (png, (w, h)) = match clipboard_png.and_then(with_pixel_size) { Some(readable) => readable, None => with_pixel_size(encoded_clipboard_image(app)?)?, }; diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 821e1ec82..8608b2c2a 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -534,3 +534,17 @@ fn an_unreadable_png_on_the_clipboard_falls_back_to_the_decoded_image() { let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); } + +#[test] +fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() { + let mut w = Win::new(); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let mut png = png.into_inner(); + png.resize(crate::MAX_CLIPBOARD_PNG_BYTES + 1, 0); + *w.clip.borrow_mut() = Clip { png: Some(png), image: Some((2, 2, vec![255; 16])), ..Clip::default() }; + w.cmd_v(CMD); + let doc = w.app.session.doc().unwrap(); + let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); + assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 80a25e481..75a9b020d 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,5 +1,7 @@ use super::*; +pub const MAX_CLIPBOARD_PNG_BYTES: usize = 256 * 1024 * 1024; + /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {