diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 03:15:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 03:15:52 +0300 |
| commit | 350e8c7e4685b12546b145bc5476eb9c3de99751 (patch) | |
| tree | 29d9301674107ba8789c4a94046945e283c0ace0 /src | |
| parent | 77fcde0a2736c3927d1bc589bd9b4d0173a9df46 (diff) | |
fix: validate clipboard image payloads
refs #534
Diffstat (limited to 'src')
| -rw-r--r-- | src/platform/linux.rs | 196 |
1 files changed, 192 insertions, 4 deletions
diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 5b6a6d3c..9a44d02e 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -216,17 +216,20 @@ pub fn read_clipboard_image() -> Option<ClipboardImage> { ("image/bmp", "bmp"), ] { if std::env::var_os("WAYLAND_DISPLAY").is_some() { - if let Some(bytes) = read_clipboard_image_with_command("wl-paste", &["--type", mime]) { - return Some(ClipboardImage { bytes, extension }); + if let Some(image) = + read_validated_clipboard_image("wl-paste", &["--type", mime], extension) + { + return Some(image); } } if std::env::var_os("DISPLAY").is_some() { - if let Some(bytes) = read_clipboard_image_with_command( + if let Some(image) = read_validated_clipboard_image( "xclip", &["-selection", "clipboard", "-t", mime, "-o"], + extension, ) { - return Some(ClipboardImage { bytes, extension }); + return Some(image); } } } @@ -234,6 +237,35 @@ pub fn read_clipboard_image() -> Option<ClipboardImage> { None } +fn read_validated_clipboard_image( + program: &str, + args: &[&str], + extension: &'static str, +) -> Option<ClipboardImage> { + let bytes = read_clipboard_image_with_command(program, args)?; + if !bytes_match_image_signature(extension, &bytes) { + return None; + } + Some(ClipboardImage { bytes, extension }) +} + +fn bytes_match_image_signature(extension: &str, bytes: &[u8]) -> bool { + match extension { + "png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]), + "gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && bytes[8..12] == *b"WEBP", + "bmp" => { + if bytes.len() < 26 || !bytes.starts_with(b"BM") { + return false; + } + let offset = u32::from_le_bytes([bytes[10], bytes[11], bytes[12], bytes[13]]) as usize; + (26..=bytes.len()).contains(&offset) + } + _ => false, + } +} + /// Show a native desktop notification through libnotify's command-line helper. pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> { show_desktop_notification_with_command(title, body, |program| Command::new(program)) @@ -543,6 +575,162 @@ mod tests { } #[test] + fn read_clipboard_image_rejects_xclip_text_served_for_image_target() { + let _guard = env_lock().lock().unwrap(); + let temp_dir = + std::env::temp_dir().join(format!("herdr-fake-xclip-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let fake_xclip = temp_dir.join("xclip"); + std::fs::write(&fake_xclip, "#!/bin/sh\nprintf '# Tasks'\n") + .expect("fake xclip should be written"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(&fake_xclip) + .expect("fake xclip metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&fake_xclip, permissions) + .expect("fake xclip should be executable"); + } + + let old_path = std::env::var_os("PATH"); + let test_path = match old_path.as_ref() { + Some(path) => { + let mut paths = vec![temp_dir.clone()]; + paths.extend(std::env::split_paths(path)); + std::env::join_paths(paths).expect("test path should be valid") + } + None => temp_dir.clone().into_os_string(), + }; + + unsafe { + std::env::remove_var("WAYLAND_DISPLAY"); + std::env::set_var("DISPLAY", ":0"); + std::env::set_var("PATH", test_path); + } + + let result = read_clipboard_image(); + + unsafe { + match old_path { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + } + let _ = std::fs::remove_file(fake_xclip); + let _ = std::fs::remove_dir(temp_dir); + + assert_eq!(result, None); + } + + #[test] + fn read_clipboard_image_rejects_wayland_xclip_fallback_text_for_image_target() { + let _guard = env_lock().lock().unwrap(); + let temp_dir = + std::env::temp_dir().join(format!("herdr-fake-wayland-xclip-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let fake_wl_paste = temp_dir.join("wl-paste"); + let fake_xclip = temp_dir.join("xclip"); + std::fs::write(&fake_wl_paste, "#!/bin/sh\nexit 1\n") + .expect("fake wl-paste should be written"); + std::fs::write(&fake_xclip, "#!/bin/sh\nprintf '# Tasks'\n") + .expect("fake xclip should be written"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + for command in [&fake_wl_paste, &fake_xclip] { + let mut permissions = std::fs::metadata(command) + .expect("fake clipboard command metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(command, permissions) + .expect("fake clipboard command should be executable"); + } + } + + let old_path = std::env::var_os("PATH"); + let test_path = match old_path.as_ref() { + Some(path) => { + let mut paths = vec![temp_dir.clone()]; + paths.extend(std::env::split_paths(path)); + std::env::join_paths(paths).expect("test path should be valid") + } + None => temp_dir.clone().into_os_string(), + }; + + unsafe { + std::env::set_var("WAYLAND_DISPLAY", "wayland-0"); + std::env::set_var("DISPLAY", ":0"); + std::env::set_var("PATH", test_path); + } + + let result = read_clipboard_image(); + + unsafe { + match old_path { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + } + let _ = std::fs::remove_file(fake_wl_paste); + let _ = std::fs::remove_file(fake_xclip); + let _ = std::fs::remove_dir(temp_dir); + + assert_eq!(result, None); + } + + #[test] + fn read_validated_clipboard_image_accepts_real_png_payload() { + assert_eq!( + read_validated_clipboard_image( + "sh", + &["-c", "printf '\\211PNG\\r\\n\\032\\nrest-of-image'"], + "png" + ), + Some(ClipboardImage { + bytes: b"\x89PNG\r\n\x1a\nrest-of-image".to_vec(), + extension: "png", + }) + ); + } + + #[test] + fn image_signatures_match_only_their_format() { + assert!(bytes_match_image_signature("png", b"\x89PNG\r\n\x1a\n...")); + assert!(bytes_match_image_signature( + "jpg", + &[0xFF, 0xD8, 0xFF, 0xE0] + )); + assert!(bytes_match_image_signature("gif", b"GIF87a...")); + assert!(bytes_match_image_signature("gif", b"GIF89a...")); + assert!(bytes_match_image_signature( + "webp", + b"RIFF\x10\x00\x00\x00WEBPVP8 " + )); + + let mut bmp = vec![0u8; 26]; + bmp[..2].copy_from_slice(b"BM"); + bmp[10] = 26; + assert!(bytes_match_image_signature("bmp", &bmp)); + + assert!(!bytes_match_image_signature("png", b"# Tasks")); + assert!(!bytes_match_image_signature("jpg", b"plain clipboard text")); + assert!(!bytes_match_image_signature("gif", b"")); + assert!(!bytes_match_image_signature("webp", b"RIFF but not webp")); + assert!(!bytes_match_image_signature("bmp", b"\x89PNG\r\n\x1a\n")); + assert!(!bytes_match_image_signature( + "bmp", + b"BM text is not a bitmap" + )); + assert!(!bytes_match_image_signature("svg", b"<svg></svg>")); + } + + #[test] fn desktop_notification_separates_option_like_titles() { let _guard = env_lock().lock().unwrap(); unsafe { |
