From 7853db061e33c398795086d22e4533c384d7ce15 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:27:17 -0400 Subject: [PATCH] fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3) --- crates/relicario-core/src/imgsecret.rs | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/relicario-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs index d512036..e495529 100644 --- a/crates/relicario-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0; /// this cannot hold enough 8x8 blocks for reliable embedding. const MIN_DIMENSION: u32 = 100; +/// Maximum image dimension (width or height) in pixels. Images larger than +/// this are rejected before full decode to prevent DoS via attacker-supplied +/// oversized JPEGs (audit M3). +pub const MAX_DIMENSION: u32 = 10_000; + /// Number of secret bits to embed: 256 bits = 32 bytes. const SECRET_BITS: usize = 256; @@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [ (2, 3), // zig-zag 14-17 ]; +// ─── Dimension guard ───────────────────────────────────────────────────────── + +/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which +/// carries the image dimensions in bytes 5..=8 of its segment. +/// +/// This peek does NOT decode any pixel data, so an oversized JPEG header is +/// rejected in O(marker-count) time without allocating a frame buffer. +fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> { + let mut i = 0; + while i + 1 < jpeg.len() { + if jpeg[i] != 0xFF { + i += 1; + continue; + } + let marker = jpeg[i + 1]; + match marker { + 0xD8 | 0xD9 => { + i += 2; + continue; + } // SOI / EOI + 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + // SOFn — height in [i+5..i+7], width in [i+7..i+9] + if i + 9 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated SOF marker".into())); + } + let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32; + let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32; + return Ok((width, height)); + } + _ => { + if i + 3 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated marker segment".into())); + } + let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + i += 2 + seg_len; + } + } + } + Err(RelicarioError::ImgSecret( + "no SOF marker found in JPEG".into(), + )) +} + +/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`]. +/// +/// Called at the entry point of both `embed` and `extract` to prevent +/// attacker-supplied 32000×32000 images from wedging the WASM service worker +/// during the expensive DCT extraction pass (audit M3). +fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> { + let (w, h) = peek_jpeg_dimensions(jpeg)?; + if w > MAX_DIMENSION || h > MAX_DIMENSION { + return Err(RelicarioError::ImgSecret(format!( + "image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap" + ))); + } + Ok(()) +} + // ─── YChannel ──────────────────────────────────────────────────────────────── /// The luminance (Y) channel of an image, stored as a flat array of f64 values. @@ -601,6 +664,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result Result> { + enforce_dimension_cap(carrier_jpeg)?; let mut y = extract_y_channel(carrier_jpeg)?; if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { @@ -672,6 +736,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { /// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered /// (image was never watermarked, or was too heavily recompressed/cropped). pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + enforce_dimension_cap(jpeg_bytes)?; extract_with_crop_recovery(jpeg_bytes) } @@ -1015,6 +1080,30 @@ mod tests { assert_eq!(extracted, secret); } + #[test] + fn rejects_oversized_image_without_full_decode() { + // Synthesize a JPEG header claiming 20000x20000 dimensions. + // The actual pixel data is irrelevant — the dimension peek should bail out + // before decoding any pixels. + let jpeg = build_oversized_jpeg_header(20_000, 20_000); + let result = extract(&jpeg); + assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension"))); + } + + fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec { + // SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI + let mut v = vec![0xFF, 0xD8]; // SOI + v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0 + v.extend_from_slice(b"JFIF\0"); + v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]); + v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0 + v.extend_from_slice(&height.to_be_bytes()); + v.extend_from_slice(&width.to_be_bytes()); + v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]); + v.extend_from_slice(&[0xFF, 0xD9]); // EOI + v + } + #[test] fn embed_extract_survives_10pct_crop() { let jpeg = make_test_jpeg(400, 300);