diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6e509cc..46cc0b0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.93.6" +version = "0.93.7" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1535031..b6fd05d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.93.6" +version = "0.93.7" edition = "2021" default-run = "mangalord" diff --git a/backend/src/analysis/vision.rs b/backend/src/analysis/vision.rs index 5313d75..a668b8b 100644 --- a/backend/src/analysis/vision.rs +++ b/backend/src/analysis/vision.rs @@ -55,6 +55,12 @@ struct SliceParams { overlap: f64, tall_threshold: f64, max_slices: usize, + /// Hard cap on the **decoded** pixel count, enforced as an allocation + /// limit on the decoder itself. `max_pixels` only downscales *after* a + /// full decode, so without this a tiny WebP/JPEG/PNG declaring huge + /// dimensions would OOM the blocking worker (decompression bomb). + /// Shared with the OCR backend's cap (`ANALYSIS_OCR_MAX_DECODE_PIXELS`). + max_decode_pixels: u64, } /// Result of the (blocking-pool) prep pass: a self-contained set of byte @@ -86,11 +92,30 @@ enum PreparedAnalysis { /// JPEG-encoder regression indistinguishable from "the page was just /// garbage." Emit a `warn` on each fallback so an operator can grep /// for "vision prep fell through" and tell the two apart. +/// Decode an encoded page image with a hard allocation cap so a +/// decompression bomb — a tiny WebP/JPEG/PNG header declaring enormous +/// dimensions — can't OOM the blocking worker before we get a chance to +/// downscale. The `image` crate's default reader applies no such bound; +/// format-specific self-limits (strongest for PNG) don't cover WebP/JPEG, +/// which manga pages commonly use. `4 bytes/px` (RGBA) leaves headroom +/// over any single intermediate the decoder allocates per pixel. Mirrors +/// `ocr::decode_rgb8_within`. +fn decode_within(image: &[u8], max_decode_pixels: u64) -> Option { + use std::io::Cursor; + let mut reader = image::ImageReader::new(Cursor::new(image)) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(max_decode_pixels.saturating_mul(4)); + reader.limits(limits); + reader.decode().ok() +} + fn prepare_analysis(image: &[u8], params: SliceParams) -> PreparedAnalysis { - let Some(img) = image::load_from_memory(image).ok() else { + let Some(img) = decode_within(image, params.max_decode_pixels) else { tracing::warn!( bytes = image.len(), - "vision prep fell through to Undecodable: image::load_from_memory failed" + "vision prep fell through to Undecodable: decode failed or exceeded pixel cap" ); return PreparedAnalysis::Undecodable; }; @@ -162,6 +187,7 @@ impl VisionClient { overlap: cfg.slice_overlap, tall_threshold: cfg.tall_aspect_threshold, max_slices: cfg.max_slices, + max_decode_pixels: cfg.ocr_max_decode_pixels, }, } } @@ -779,9 +805,40 @@ mod tests { overlap: 0.12, tall_threshold: 1.6, max_slices: 16, + max_decode_pixels: 100_000_000, } } + fn encode_webp(w: u32, h: u32) -> Vec { + use image::{DynamicImage, ImageFormat, RgbImage}; + let img = DynamicImage::ImageRgb8(RgbImage::new(w, h)); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, ImageFormat::WebP).expect("encode webp"); + buf.into_inner() + } + + #[test] + fn decode_within_rejects_oversize_webp() { + // Non-PNG decompression-bomb coverage: a real 2000×2000 WebP (4 MP) + // must be refused when the decode cap is set below its pixel count. + // The allocation limit trips inside the decoder rather than the + // full frame being materialized. Manga pages are commonly WebP/JPEG, + // where the format's own self-limits are weaker than PNG's. + let webp = encode_webp(2000, 2000); + assert!(decode_within(&webp, 1_000).is_none(), "cap below pixels must reject"); + // A generous cap decodes the same image fine. + assert!(decode_within(&webp, 100_000_000).is_some(), "within cap must decode"); + } + + #[test] + fn prepare_analysis_falls_back_to_undecodable_on_decode_bomb() { + // End-to-end: an over-cap image drives the prep pass to the + // Undecodable fallback (raw-bytes path) instead of decoding it. + let webp = encode_webp(2000, 2000); + let p = SliceParams { max_decode_pixels: 1_000, ..params() }; + assert!(matches!(prepare_analysis(&webp, p), PreparedAnalysis::Undecodable)); + } + fn ocr(text: &str, kind: &str) -> OcrResult { OcrResult { text: text.into(), @@ -1136,6 +1193,7 @@ mod tests { overlap: 0.05, tall_threshold: 1.8, max_slices: 6, + max_decode_pixels: 100_000_000, } } diff --git a/frontend/package.json b/frontend/package.json index 9c8582a..3437356 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.93.6", + "version": "0.93.7", "private": true, "type": "module", "scripts": {