fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3)
This commit is contained in:
@@ -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<Vec<u
|
||||
/// or does not have enough blocks for reliable embedding.
|
||||
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
|
||||
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)?;
|
||||
|
||||
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
|
||||
/// (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<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]
|
||||
fn embed_extract_survives_10pct_crop() {
|
||||
let jpeg = make_test_jpeg(400, 300);
|
||||
|
||||
Reference in New Issue
Block a user