diff --git a/core/Cargo.lock b/core/Cargo.lock index 47521506..845eba7c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "hyper 0.14.32", "hyper-util", "hyper-ws-listener", + "image", "iroh", "iroh-blobs", "libc", @@ -1567,6 +1568,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2503,8 +2513,22 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -4142,6 +4166,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -4366,6 +4403,12 @@ dependencies = [ "image", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -7322,3 +7365,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 46edac95..eb1ad18c 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -106,6 +106,8 @@ flate2 = "1.0" # TOTP 2FA totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] } qrcode = "0.14" +# Paid image previews must be degraded on the server, never by browser CSS. +image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp"] } data-encoding = "2.6" zeroize = { version = "1.8.2", features = ["derive"] } diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index c7a7ef1b..f9e30196 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -544,11 +544,11 @@ async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result, String), - /// Blurred preview for paid image (full bytes, frontend applies blur). + /// Small, server-blurred JPEG for a paid image; never the original bytes. BlurPreview(Vec, String), - /// Truncated preview for paid video (first ~2% of bytes). + /// Bounded preview for paid video/audio (at most 10% of bytes). TruncatedPreview(Vec, String, u64), /// A preview can't be produced for this media without re-encoding (e.g. a /// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't @@ -612,6 +612,53 @@ async fn mp4_is_faststart(path: &std::path::Path) -> Option { /// - Videos: first 2% of file bytes (minimum 512KB for codec headers) /// - Other: not available /// For free/peers-only content, returns the full file. +/// Decode only bounded raster inputs, discard original metadata, then reduce +/// and blur pixels before encoding a new image. Browser styling is not a gate. +async fn blurred_image_preview(path: PathBuf) -> Result> { + static WORKERS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); + let permit = WORKERS.try_acquire().context("Preview workers busy")?; + tokio::task::spawn_blocking(move || { + use std::io::{Cursor, Read}; + use std::os::unix::fs::OpenOptionsExt; + let _permit = permit; + const MAX_INPUT: u64 = 16 * 1024 * 1024; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path)?; + let meta = file.metadata()?; + anyhow::ensure!( + meta.is_file() && meta.len() <= MAX_INPUT, + "Invalid preview source" + ); + let mut encoded = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut encoded)?; + anyhow::ensure!( + encoded.len() as u64 <= MAX_INPUT, + "Preview source grew too large" + ); + let mut reader = image::ImageReader::new(Cursor::new(encoded)).with_guessed_format()?; + anyhow::ensure!( + matches!( + reader.format(), + Some(image::ImageFormat::Jpeg | image::ImageFormat::Png | image::ImageFormat::WebP) + ), + "Unsupported preview image" + ); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(32 * 1024 * 1024); + reader.limits(limits); + let reduced = reader.decode()?.thumbnail(160, 160).blur(8.0).to_rgb8(); + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(reduced).write_to(&mut output, image::ImageFormat::Jpeg)?; + Ok(output.into_inner()) + }) + .await + .context("Preview worker failed")? +} + pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result { let catalog = load_catalog(data_dir).await?; let item = match catalog.items.iter().find(|i| i.id == id) { @@ -619,8 +666,11 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result return Ok(PreviewResult::NotFound), }; - // Check availability — don't preview hidden items - if matches!(item.availability, Availability::Nobody) { + // This endpoint is anonymous. It must not become an alternate download + // route around peer-only or specific-recipient access checks. + if !matches!(item.availability, Availability::AllPeers) + || matches!(item.access, AccessControl::PeersOnly) + { return Ok(PreviewResult::NotFound); } @@ -633,16 +683,10 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result { let mime = &item.mime_type; if mime.starts_with("image/") { - // Serve full image — frontend applies CSS blur - let bytes = fs::read(&file_path) - .await - .context("Failed to read preview file")?; - debug!( - "Serving blur preview for paid image '{}' ({} bytes)", - id, - bytes.len() - ); - Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone())) + match blurred_image_preview(file_path).await { + Ok(bytes) => Ok(PreviewResult::BlurPreview(bytes, "image/jpeg".into())), + Err(_) => Ok(PreviewResult::PreviewUnavailable), + } } else if mime.starts_with("video/") || mime.starts_with("audio/") { // A byte-prefix preview only plays if the container's index is at // the front. For MP4/MOV that means the `moov` atom must precede @@ -664,12 +708,16 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(CONTENT_DIR)) + .await + .unwrap(); + fs::write(dir.path().join(CONTENT_DIR).join("preview.bin"), bytes) + .await + .unwrap(); + save_catalog( + dir.path(), + &ContentCatalog { + items: vec![ContentItem { + id: "preview-test".into(), + filename: "preview.bin".into(), + mime_type: mime.into(), + size_bytes: bytes.len() as u64, + description: String::new(), + added_at: String::new(), + access, + availability, + }], + }, + ) + .await + .unwrap(); + dir + } + + fn paid() -> AccessControl { + AccessControl::Paid { + price_sats: 10, + accepted: vec!["ecash".into()], + } + } + + #[tokio::test] + async fn anonymous_preview_never_bypasses_restricted_sharing() { + for (access, availability) in [ + (AccessControl::Free, Availability::Nobody), + (paid(), Availability::Nobody), + ( + AccessControl::Free, + Availability::Specific { + peers: vec!["did:key:allowed".into()], + }, + ), + ( + paid(), + Availability::Specific { + peers: vec!["did:key:allowed".into()], + }, + ), + (AccessControl::PeersOnly, Availability::AllPeers), + ] { + let dir = fixture(b"PRIVATE", "image/png", access, availability).await; + assert!(matches!( + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap(), + PreviewResult::NotFound + )); + } + } + + #[tokio::test] + async fn paid_image_preview_is_degraded_and_drops_original_metadata() { + use std::io::Cursor; + let source = image::RgbImage::from_fn(640, 320, |x, y| { + let c = if (x / 8 + y / 8) % 2 == 0 { 0 } else { 255 }; + image::Rgb([c, c, c]) + }); + let original = image::DynamicImage::ImageRgb8(source); + let mut encoded = Cursor::new(Vec::new()); + original + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let mut bytes = encoded.into_inner(); + const PRIVATE: &[u8] = b"PRIVATE-ORIGINAL-METADATA"; + bytes.extend_from_slice(PRIVATE); + let dir = fixture(&bytes, "image/png", paid(), Availability::AllPeers).await; + let PreviewResult::BlurPreview(preview, mime) = + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap() + else { + panic!("No degraded preview") + }; + assert_eq!(mime, "image/jpeg"); + assert_ne!(preview, bytes); + assert!(!preview.windows(PRIVATE.len()).any(|w| w == PRIVATE)); + let decoded = image::load_from_memory(&preview).unwrap().to_rgb8(); + assert!(decoded.width() <= 160 && decoded.height() <= 160); + assert!( + decoded.pixels().all(|p| p[0] > 30 && p[0] < 225), + "High-contrast original detail must be blurred" + ); + assert_eq!( + fs::read(dir.path().join(CONTENT_DIR).join("preview.bin")) + .await + .unwrap(), + bytes + ); + + // Malformed/unsupported and oversized inputs never fall back to the paid original. + fs::write( + dir.path().join(CONTENT_DIR).join("preview.bin"), + b"private source", + ) + .await + .unwrap(); + assert!(matches!( + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap(), + PreviewResult::PreviewUnavailable + )); + let file = fs::OpenOptions::new() + .write(true) + .open(dir.path().join(CONTENT_DIR).join("preview.bin")) + .await + .unwrap(); + file.set_len(17 * 1024 * 1024).await.unwrap(); + assert!(matches!( + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap(), + PreviewResult::PreviewUnavailable + )); + } + + #[tokio::test] + async fn paid_audio_preview_does_not_release_small_files_or_allocate_a_whole_film() { + let dir = fixture( + &vec![42; 1000], + "audio/mpeg", + paid(), + Availability::AllPeers, + ) + .await; + let PreviewResult::TruncatedPreview(bytes, _, total) = + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap() + else { + panic!("No audio preview") + }; + assert_eq!(total, 1000); + assert_eq!(bytes, vec![42; 100]); + let file = fs::OpenOptions::new() + .write(true) + .open(dir.path().join(CONTENT_DIR).join("preview.bin")) + .await + .unwrap(); + file.set_len(100 * 1024 * 1024).await.unwrap(); + let PreviewResult::TruncatedPreview(bytes, _, total) = + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap() + else { + panic!("No bounded preview") + }; + assert_eq!(total, 100 * 1024 * 1024); + assert_eq!(bytes.len(), 8 * 1024 * 1024); + file.set_len(0).await.unwrap(); + assert!(matches!( + serve_content_preview(dir.path(), "preview-test") + .await + .unwrap(), + PreviewResult::PreviewUnavailable + )); + } + + #[tokio::test] + async fn public_free_preview_remains_available() { + let dir = fixture( + b"public", + "text/plain", + AccessControl::Free, + Availability::AllPeers, + ) + .await; + assert!( + matches!(serve_content_preview(dir.path(), "preview-test").await.unwrap(), PreviewResult::FullContent(bytes, _) if bytes == b"public") + ); + } +} diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index aff508c5..673eb40b 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -246,3 +246,25 @@ same iframe and Stop. Logs: `/tmp/archy-integrated-v4v-browser-origin-4.log` production notification suppression were used. Production UI rebuild including the new card footers is running; signed-install and physical companion gates remain open. + + +### Paid-preview access boundary (new finding during FIPS work) + +Source review found that the anonymous preview route returned full paid image +bytes and relied on browser CSS blur. It also served previews for restricted +shares without checking a recipient, and the minimum byte-prefix size could +return a small paid audio/video file in full. + +The candidate now produces a fresh, small blurred JPEG on the server, drops +original metadata, bounds raster input/dimensions/decoder concurrency, and fails +closed on unsupported images. Anonymous previews reject specific-recipient and +peer-only content. Audio/video prefixes are at most 10% and 8 MiB, with no +minimum that can reveal the full original. Four isolated regression cases are +compiling; no deployed fix or full preview acceptance is claimed yet. + +The preceding integrated backend suite completed: 1,718 passed, zero failures, +five listed ignores. The ignores cover opt-in real AI providers, RNode hardware, +Reticulum daemons, live Minibits and the subprocess permission helper (which its +parent test executes separately). A production build of the earlier integration +is running from detached `11f016a9`; it does not include this new preview fix and +must not be described as the final release candidate.