Windows paste: clipboard pictures are checked from their header before anything decodes them (#3623)

On Windows the paste path asked arboard (3.6.1) for decoded pixels.
arboard decodes the registered "PNG" format and CF_DIBV5 with no image
limits, so a clipboard item of a few dozen bytes whose header declares
100,000 x 100,000 pixels made the decoder allocate tens of GB. The
allocation failure aborted the app outside Session::run's guard, before
paste_picture's size checks ever saw the picture.

On Windows the app now reads the clipboard itself (clipboard-win, the
raw read capped at 200 MB):
- "PNG" goes to Insert Picture as encoded bytes, once its header shows
  a size a paste takes (16,000 a side, 50 M pixels).
- Otherwise CF_DIBV5 or CF_DIB is probed through
  wordcraft_geom::picture::probe_dib, checked the same way, then decoded
  within the decode budget into pixels.

probe_dib reads a DIB in the layout the image crate expects: a 32-bit
BI_RGB DIB with an alpha mask becomes bit fields, so its alpha is kept
as arboard did. A V4 or V5 bit-field DIB without the copy of its colour
masks after the header (arboard writes one) gets that copy, because the
decoder skips 12 bytes there and would otherwise read into the pixels.

Linux keeps using arboard, which decodes the clipboard's PNG within the
image crate's default 512 MB limit. macOS keeps it too: arboard decodes
the pasteboard's TIFF there, which can still reach the JPEG strip
decoder of #3645; that path is untouched here because it can't be built
or run on this machine.

cargo check and clippy for x86_64-pc-windows-gnu pass on the
Windows-only code; it hasn't been run on Windows.
This commit is contained in:
VoiceLessQ committed 2026-10-24 18:01:45 -01:00
1 parent 8c9b8faa59
commit 5929374704
3 files changed
+242 -12

No files matched your search

+41 -3
View File
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
{
return Some(ClipboardPicture::Files(files));
}
decoded_picture(&mut clipboard)
}
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
/// with no size limits (#3623).
#[cfg(target_os = "windows")]
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
platform::picture()
}
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -187,6 +201,7 @@ mod platform {
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
@@ -205,8 +220,31 @@ mod platform {
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
/// it holds neither.
pub fn picture() -> Option<ClipboardPicture> {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let Ok(_open) = Clipboard::new_attempts(10) else {
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
};
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
}
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
}
/// A clipboard format's bytes, refused past the largest picture a paste takes.
fn read(format: u32) -> Result<Vec<u8>, String> {
match raw::size(format) {
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
None => return Err("the clipboard picture is empty".into()),
}
let mut out = Vec::new();
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
Ok(out)
}
pub fn can_set() -> bool {
+122 -9
View File
@@ -1,9 +1,11 @@
//! Decoding a picture within a memory budget. Every crate that decodes a document's pictures goes
//! through [`probe`]: the header gives the format and size, and the decode is refused before
//! anything is allocated when its peak would pass the budget. The peak counts what a decoder holds
//! beside its output (a progressive JPEG's coefficients, #3644), and a picture whose decoder can't
//! be held to a budget is not decoded at all (a TIFF with JPEG strips, #3645).
//! Decoding a picture within a memory budget. Every crate that decodes a document's or the
//! clipboard's pictures goes through [`probe`] (or [`probe_dib`]): the header gives the format and
//! size, and the decode is refused before anything is allocated when its peak would pass the
//! budget. The peak counts what a decoder holds beside its output (a progressive JPEG's
//! coefficients, #3644), and a picture whose decoder can't be held to a budget is not decoded at
//! all (a TIFF with JPEG strips, #3645).
use std::borrow::Cow;
use std::io::Cursor;
use image::{DynamicImage, ImageDecoder, ImageFormat};
@@ -40,9 +42,11 @@ fn broken(e: image::ImageError) -> Refusal {
/// What a picture's header says: its format, its size and the memory decoding it takes. Reading
/// it allocates no pixels.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub struct Probe<'a> {
bytes: &'a [u8],
bytes: Cow<'a, [u8]>,
/// A DIB: a BMP without its file header, as the Windows clipboard holds one.
dib: bool,
pub format: ImageFormat,
pub width: u32,
pub height: u32,
@@ -70,7 +74,19 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
};
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes, format, width, height, peak })
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
/// one (`CF_DIB`, `CF_DIBV5`). Its layout is first made the one the decoder reads (see
/// [`dib_layout`]).
pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let dib = dib_layout(dib);
let (width, height, peak) = {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
@@ -105,7 +121,12 @@ impl Probe<'_> {
limits.max_image_width = Some(self.width);
limits.max_image_height = Some(self.height);
limits.max_alloc = Some(budget);
let mut reader = image::ImageReader::with_format(Cursor::new(self.bytes), self.format);
if self.dib {
let mut decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).map_err(broken)?;
decoder.set_limits(limits).map_err(broken)?;
return DynamicImage::from_decoder(decoder).map_err(broken);
}
let mut reader = image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format);
reader.limits(limits);
reader.decode().map_err(broken)
}
@@ -219,6 +240,37 @@ fn tiff_compression(bytes: &[u8]) -> Option<u16> {
Some(1)
}
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
/// is set but whose compression says `BI_RGB` is marked `BI_BITFIELDS` (with the usual colour
/// masks when it gives none), so its alpha is kept as Windows programs mean it; arboard makes the
/// same change. Bit fields then need the colour masks after the header too, where Windows puts a
/// copy and the decoder skips them; a DIB without that copy (arboard writes one) gets it.
fn dib_layout(mut dib: Vec<u8>) -> Vec<u8> {
let u32_at = |d: &[u8], at: usize| d.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
let Some(size) = u32_at(&dib, 0).filter(|&s| s >= 108).and_then(|s| usize::try_from(s).ok()) else { return dib };
let put = |d: &mut Vec<u8>, at: usize, v: u32| {
if let Some(b) = d.get_mut(at..at + 4) {
b.copy_from_slice(&v.to_le_bytes());
}
};
if dib.get(14..16) == Some(&[32, 0]) && u32_at(&dib, 16) == Some(0) && u32_at(&dib, 52) == Some(0xFF00_0000) {
put(&mut dib, 16, 3);
if (40..52).step_by(4).all(|at| u32_at(&dib, at) == Some(0)) {
put(&mut dib, 40, 0x00FF_0000);
put(&mut dib, 44, 0x0000_FF00);
put(&mut dib, 48, 0x0000_00FF);
}
}
if u32_at(&dib, 16) == Some(3)
&& size <= dib.len()
&& let Some(masks) = dib.get(40..52).map(<[u8]>::to_vec)
&& dib.get(size..size + 12) != Some(masks.as_slice())
{
dib.splice(size..size, masks);
}
dib
}
#[cfg(test)]
mod tests {
use super::*;
@@ -363,4 +415,65 @@ mod tests {
assert_eq!(tiff_compression(&tiff), Some(1));
assert_eq!(decode(&tiff, 1 << 20).unwrap().to_rgb8().get_pixel(4, 2).0, [9, 8, 7]);
}
/// A BITMAPV5HEADER DIB of 32-bit pixels, bottom-up, with `alpha_mask`: `BI_RGB`, or with
/// `bitfields` the usual masks and `BI_BITFIELDS`, their copy after the header when `copy`.
fn dib(w: i32, h: i32, alpha_mask: u32, bitfields: bool, copy: bool, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut d = vec![0u8; 124];
d[0..4].copy_from_slice(&124u32.to_le_bytes());
d[4..8].copy_from_slice(&w.to_le_bytes());
d[8..12].copy_from_slice(&h.to_le_bytes());
d[12..14].copy_from_slice(&1u16.to_le_bytes());
d[14..16].copy_from_slice(&32u16.to_le_bytes());
d[52..56].copy_from_slice(&alpha_mask.to_le_bytes());
if bitfields {
d[16..20].copy_from_slice(&3u32.to_le_bytes());
for (at, m) in [(40, 0x00FF_0000u32), (44, 0x0000_FF00), (48, 0x0000_00FF)] {
d[at..at + 4].copy_from_slice(&m.to_le_bytes());
}
if copy {
let masks = d[40..52].to_vec();
d.extend(masks);
}
}
for p in pixels {
d.extend(p);
}
d
}
#[test]
fn clipboard_dibs_keep_their_alpha_in_every_layout() {
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
let px = [[0, 0, 255, 128], [255, 0, 0, 255]];
// `BI_RGB` with an alpha mask (Windows programs); bit fields without the masks' copy
// after the header (arboard), and with it (Windows).
for (bitfields, copy) in [(false, false), (true, false), (true, true)] {
let p = probe_dib(dib(2, 1, 0xFF00_0000, bitfields, copy, &px)).unwrap();
assert_eq!((p.format, p.width, p.height), (ImageFormat::Bmp, 2, 1));
let rgba = p.decode(1 << 20).unwrap().to_rgba8();
assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 128], "{bitfields} {copy}");
assert_eq!(rgba.get_pixel(1, 0).0, [0, 0, 255, 255], "{bitfields} {copy}");
}
// Without an alpha mask, `BI_RGB` pixels are opaque.
let p = probe_dib(dib(2, 1, 0, false, false, &px)).unwrap();
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(0, 0).0, [255, 0, 0, 255]);
}
#[test]
fn huge_or_damaged_clipboard_dibs_are_refused() {
let p = probe_dib(dib(60_000, 60_000, 0xFF00_0000, false, false, &[])).unwrap();
assert_eq!((p.width, p.height), (60_000, 60_000));
assert!(!p.fits(crate::MAX_DECODED_BYTES));
assert!(matches!(p.decode(crate::MAX_DECODED_BYTES), Err(Refusal::TooLarge { .. })));
assert!(probe_dib(vec![0; 8]).is_err());
// Cut short or lying about its header: no pixels come out, and nothing panics.
let mut short = dib(2, 1, 0xFF00_0000, true, false, &[]);
short.truncate(100);
let mut lying = dib(2, 1, 0xFF00_0000, true, false, &[[0; 4], [0; 4]]);
lying[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
for d in [short, lying] {
assert!(probe_dib(d).and_then(|p| p.decode(1 << 20)).is_err());
}
}
}
+79
View File
@@ -56,6 +56,47 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u
Ok(png)
}
/// Whether a paste takes a `width` × `height` picture: the sides and pixels [`png_from_rgba`]
/// allows.
fn pastes(width: u32, height: u32) -> bool {
let (w, h) = (width as usize, height as usize);
w <= MAX_SIDE && h <= MAX_SIDE && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
}
fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
}
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
/// its header when a paste can't take its size, before anything decodes it (#3623).
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
Ok((w, h)) => too_large_to_paste(w, h),
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
}
}
/// A DIB from the Windows clipboard (`CF_DIBV5`, `CF_DIB`) as pixels: its size is checked from
/// its header, then it is decoded within the decode budget. arboard decoded it with no limits
/// before anything was checked (#3623).
pub fn dib_picture(dib: Vec<u8>) -> ClipboardPicture {
let p = match wordcraft_geom::picture::probe_dib(dib) {
Ok(p) => p,
Err(e) => return ClipboardPicture::Unreadable(e.to_string()),
};
if !pastes(p.width, p.height) {
return too_large_to_paste(p.width, p.height);
}
match p.decode(wordcraft_geom::MAX_DECODED_BYTES) {
Ok(img) => {
let rgba = img.into_rgba8();
ClipboardPicture::Pixels { width: rgba.width() as usize, height: rgba.height() as usize, rgba: rgba.into_raw() }
}
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
}
}
fn is_picture_file(path: &Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
@@ -184,6 +225,44 @@ mod tests {
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
}
/// A BITMAPV5HEADER DIB of 32-bit `BI_RGB` pixels (bottom-up) with an alpha mask, as Windows
/// programs put one on the clipboard.
fn dib(w: i32, h: i32, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut d = vec![0u8; 124];
d[0..4].copy_from_slice(&124u32.to_le_bytes());
d[4..8].copy_from_slice(&w.to_le_bytes());
d[8..12].copy_from_slice(&h.to_le_bytes());
d[12..14].copy_from_slice(&1u16.to_le_bytes());
d[14..16].copy_from_slice(&32u16.to_le_bytes());
d[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes());
d.extend(pixels.iter().flatten());
d
}
/// #3623: what the Windows clipboard holds is checked from its header before it is decoded.
#[test]
fn windows_clipboard_pictures_are_checked_from_their_headers() {
let mut png = Vec::new();
image::RgbaImage::new(3, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(matches!(encoded_picture(png.clone()), ClipboardPicture::Encoded(b) if b == png));
let mut wide = Vec::new();
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
panic!("pixels")
};
assert_eq!((width, height, rgba), (2, 1, vec![255, 0, 0, 128, 0, 0, 255, 255]));
// A header claiming 60000 × 60000 over no pixels is refused unread.
assert!(matches!(dib_picture(dib(60_000, 60_000, &[])), ClipboardPicture::Unreadable(e) if e.contains("too large")));
assert!(matches!(dib_picture(vec![0; 10]), ClipboardPicture::Unreadable(_)));
// And a DIB pastes as a picture.
let mut a = app_with(|| Some(dib_picture(dib(2, 2, &[[9, 9, 9, 255]; 4]))));
a.run("edit.paste", json!({})).unwrap();
assert_eq!(pictures(&mut a), 1);
}
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
#[test]
fn hostile_clipboard_pictures_are_refused() {