fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3)

This commit is contained in:
adlee-was-taken
2026-04-19 21:27:17 -04:00
parent 3e0cafb269
commit 7853db061e

View File

@@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0;
/// this cannot hold enough 8x8 blocks for reliable embedding. /// this cannot hold enough 8x8 blocks for reliable embedding.
const MIN_DIMENSION: u32 = 100; 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. /// Number of secret bits to embed: 256 bits = 32 bytes.
const SECRET_BITS: usize = 256; const SECRET_BITS: usize = 256;
@@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [
(2, 3), // zig-zag 14-17 (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 ──────────────────────────────────────────────────────────────── // ─── YChannel ────────────────────────────────────────────────────────────────
/// The luminance (Y) channel of an image, stored as a flat array of f64 values. /// 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<Vec<u
/// or does not have enough blocks for reliable embedding. /// or does not have enough blocks for reliable embedding.
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded. /// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> { pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
enforce_dimension_cap(carrier_jpeg)?;
let mut y = extract_y_channel(carrier_jpeg)?; let mut y = extract_y_channel(carrier_jpeg)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { 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<Vec<u8>> {
/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered /// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered
/// (image was never watermarked, or was too heavily recompressed/cropped). /// (image was never watermarked, or was too heavily recompressed/cropped).
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
enforce_dimension_cap(jpeg_bytes)?;
extract_with_crop_recovery(jpeg_bytes) extract_with_crop_recovery(jpeg_bytes)
} }
@@ -1015,6 +1080,30 @@ mod tests {
assert_eq!(extracted, secret); 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<u8> {
// 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] #[test]
fn embed_extract_survives_10pct_crop() { fn embed_extract_survives_10pct_crop() {
let jpeg = make_test_jpeg(400, 300); let jpeg = make_test_jpeg(400, 300);