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.
|
/// 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user