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 44bb20c0..ca881067 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 1eb714e9..8eba96f9 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 5185e4a8..0af14176 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 821e1ec8..8608b2c2 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 80a25e48..75a9b020 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 {