Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to zencodec are documented here.

## [Unreleased]

### Added

- `ResourceLimits::for_untrusted_input()` (with `safe_default()` alias) — a
safer starting point than `ResourceLimits::default()` for services
accepting bytes from the network or end users. Caps: 100 MP per frame,
200 MP across an animation, 16384×16384 max dims, 1 GiB memory, 256 MiB
input, 65536 frames, 1 hour duration. `ResourceLimits::default()`
continues to mean "no limits" for backwards compatibility (bc2790d).

### Changed

- `metadata::parse_exif_orientation` now delegates to the canonical
`helpers::parse_exif_orientation`. The previous local implementation was
a looser duplicate that read the orientation value as `u16` regardless
of TIFF type, missing `TIFF_LONG` (type 4) values for big-endian inputs
and lacking the IFD entry-count cap and tag-sort early-exit DoS
protections present in the helper (141238f).
- `DynDecodeJob` and `DynEncodeJob` shim setters now `debug_assert!` when
called after the inner job has been consumed by an `into_*` method,
catching the (structurally unreachable) misuse path loudly in tests and
dev builds. Release behaviour is unchanged (silent no-op). Trait
signatures are unchanged (a5b782e).

### Documentation

- Module-level docs in `policy.rs` now recommend `DecodePolicy::strict()`
as the starting point for untrusted input, paired with
`ResourceLimits::for_untrusted_input` (468073d).

## [0.1.20] - 2026-04-21

### Added
Expand Down
127 changes: 127 additions & 0 deletions src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,63 @@ impl ResourceLimits {
Self::default()
}

/// Safer default caps for processing **untrusted input**.
///
/// [`ResourceLimits::default()`] has every field `None` (no limits) for
/// backwards compatibility — that is fine for trusted, controlled input
/// but is **resource-management DoS by default** when feeding bytes
/// from the network or end users. Prefer this helper when limits are
/// not explicitly tuned by the caller.
///
/// Caps applied (chosen conservatively for typical web image workloads):
/// - `max_pixels`: 100 MP per frame (covers e.g. 12000 × 8000 stills)
/// - `max_total_pixels`: 200 MP across all frames of an animation
/// - `max_width` / `max_height`: 16384 each (typical decoder hardware ceiling)
/// - `max_memory_bytes`: 1 GiB
/// - `max_input_bytes`: 256 MiB
/// - `max_frames`: 65 536
/// - `max_animation_ms`: 1 hour
///
/// Threading is left at the default ([`ThreadingPolicy::Parallel`]).
///
/// These are intentionally **generous** — large enough that legitimate
/// inputs are not rejected, small enough that an adversarial input
/// cannot consume the whole machine. Tighten further for your specific
/// workload (e.g. a thumbnail server may want `max_pixels = 4_000_000`).
///
/// # Example
///
/// ```
/// use zencodec::ResourceLimits;
///
/// // Recommended starting point for a public image-decode service.
/// let limits = ResourceLimits::for_untrusted_input();
/// assert!(limits.max_pixels.is_some());
/// assert!(limits.max_input_bytes.is_some());
/// ```
pub fn for_untrusted_input() -> Self {
Self {
max_pixels: Some(100_000_000),
max_total_pixels: Some(200_000_000),
max_width: Some(16384),
max_height: Some(16384),
max_memory_bytes: Some(1024 * 1024 * 1024),
max_input_bytes: Some(256 * 1024 * 1024),
max_output_bytes: None,
max_frames: Some(65_536),
max_animation_ms: Some(60 * 60 * 1000),
threading: ThreadingPolicy::Parallel,
}
}

/// Alias for [`for_untrusted_input`](Self::for_untrusted_input).
///
/// Provided for callers who prefer the `safe_default` naming convention
/// (mirrors the pattern used in some other crates).
pub fn safe_default() -> Self {
Self::for_untrusted_input()
}

/// Set maximum total pixels.
pub fn with_max_pixels(mut self, max: u64) -> Self {
self.max_pixels = Some(max);
Expand Down Expand Up @@ -940,6 +997,76 @@ mod tests {
);
}

#[test]
fn for_untrusted_input_has_caps() {
let limits = ResourceLimits::for_untrusted_input();
assert!(limits.has_any());
assert!(limits.max_pixels.is_some());
assert!(limits.max_total_pixels.is_some());
assert!(limits.max_width.is_some());
assert!(limits.max_height.is_some());
assert!(limits.max_memory_bytes.is_some());
assert!(limits.max_input_bytes.is_some());
assert!(limits.max_frames.is_some());
assert!(limits.max_animation_ms.is_some());
}

#[test]
fn for_untrusted_input_rejects_oversized_image() {
use crate::{ImageFormat, ImageInfo};
let limits = ResourceLimits::for_untrusted_input();
// 30000×30000 = 900 MP, far above the 100 MP per-frame cap.
let info = ImageInfo::new(30000, 30000, ImageFormat::Jpeg);
let err = limits.check_image_info(&info).unwrap_err();
// Width is the first cap we trip (16384 < 30000).
assert!(matches!(err, LimitExceeded::Width { .. }));

// Smaller width but still huge pixel count.
let info = ImageInfo::new(10000, 12000, ImageFormat::Jpeg);
let err = limits.check_image_info(&info).unwrap_err();
assert!(matches!(err, LimitExceeded::Pixels { .. }));
}

#[test]
fn for_untrusted_input_accepts_typical_image() {
use crate::{ImageFormat, ImageInfo};
let limits = ResourceLimits::for_untrusted_input();
// 4K image — should pass.
let info = ImageInfo::new(3840, 2160, ImageFormat::Jpeg);
assert!(limits.check_image_info(&info).is_ok());
// 12 MP photo — should pass.
let info = ImageInfo::new(4000, 3000, ImageFormat::Jpeg);
assert!(limits.check_image_info(&info).is_ok());
}

#[test]
fn for_untrusted_input_rejects_oversized_input() {
let limits = ResourceLimits::for_untrusted_input();
// 1 GiB input is definitely too big.
assert!(limits.check_input_size(1024 * 1024 * 1024).is_err());
// 16 MiB input is fine.
assert!(limits.check_input_size(16 * 1024 * 1024).is_ok());
}

#[test]
fn safe_default_alias_matches_for_untrusted_input() {
assert_eq!(
ResourceLimits::safe_default(),
ResourceLimits::for_untrusted_input()
);
}

#[test]
fn default_remains_no_limits_for_backwards_compat() {
// Per the crate's stability guarantee, ResourceLimits::default()
// continues to mean "no limits" — switching to safer caps is
// opt-in via for_untrusted_input().
let limits = ResourceLimits::default();
assert!(!limits.has_any());
assert!(limits.max_pixels.is_none());
assert!(limits.max_input_bytes.is_none());
}

#[test]
fn total_pixels_display() {
use alloc::format;
Expand Down
91 changes: 48 additions & 43 deletions src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,51 +153,17 @@ impl From<&crate::ImageInfo> for Metadata {

/// Parse the EXIF Orientation tag (0x0112) from a TIFF/EXIF blob.
///
/// Delegates to the canonical implementation in
/// [`helpers::parse_exif_orientation`](crate::helpers::parse_exif_orientation),
/// which performs full bounds-checking, supports both `SHORT` and `LONG`
/// TIFF types, validates the TIFF magic, and caps IFD entry count to
/// prevent DoS from malformed data.
///
/// Handles both little-endian (`II*\0`) and big-endian (`MM\0*`) byte
/// orders. Walks IFD0 and returns the first Orientation entry found.
/// Returns `None` if the blob is malformed or no Orientation tag exists.
/// orders. Returns `None` if the blob is malformed or no Orientation
/// tag exists.
fn parse_exif_orientation(blob: &[u8]) -> Option<Orientation> {
if blob.len() < 8 {
return None;
}
let little_endian = match &blob[0..4] {
[b'I', b'I', 0x2a, 0x00] => true,
[b'M', b'M', 0x00, 0x2a] => false,
_ => return None,
};
let read_u16 = |offset: usize| -> Option<u16> {
let bytes = blob.get(offset..offset + 2)?;
Some(if little_endian {
u16::from_le_bytes([bytes[0], bytes[1]])
} else {
u16::from_be_bytes([bytes[0], bytes[1]])
})
};
let read_u32 = |offset: usize| -> Option<u32> {
let bytes = blob.get(offset..offset + 4)?;
Some(if little_endian {
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
} else {
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
})
};

let ifd0_offset = read_u32(4)? as usize;
let entry_count = read_u16(ifd0_offset)? as usize;
let entries_start = ifd0_offset.checked_add(2)?;

for i in 0..entry_count {
let entry_offset = entries_start.checked_add(i.checked_mul(12)?)?;
let tag = read_u16(entry_offset)?;
if tag == 0x0112 {
// Orientation tag: type SHORT (3), count 1, value inline at +8.
let value = read_u16(entry_offset + 8)?;
if value > 0 && value <= 8 {
return Orientation::from_exif(value as u8);
}
}
}
None
crate::helpers::parse_exif_orientation(blob)
}

#[cfg(test)]
Expand Down Expand Up @@ -372,6 +338,45 @@ mod tests {
assert_eq!(meta.orientation, Orientation::Rotate270);
}

/// Build TIFF with the orientation tag stored as TIFF_LONG (type 4)
/// instead of SHORT (type 3). The previous loose parser in this file
/// only read u16 at +8 regardless of type, so for big-endian LONG it
/// would read the high zero bytes and miss the value. The delegated
/// helper handles both types correctly.
fn build_exif_with_long_orientation(value: u32, big_endian: bool) -> alloc::vec::Vec<u8> {
let mut v = alloc::vec::Vec::new();
if big_endian {
v.extend_from_slice(b"MM\x00\x2a");
v.extend_from_slice(&8u32.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes());
v.extend_from_slice(&0x0112u16.to_be_bytes());
v.extend_from_slice(&4u16.to_be_bytes()); // type = LONG
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&value.to_be_bytes());
} else {
v.extend_from_slice(b"II\x2a\x00");
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&0x0112u16.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes()); // type = LONG
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&value.to_le_bytes());
}
v
}

#[test]
fn parse_exif_orientation_accepts_long_type_be() {
let blob = build_exif_with_long_orientation(6, true);
assert_eq!(parse_exif_orientation(&blob), Some(Orientation::Rotate90));
}

#[test]
fn parse_exif_orientation_accepts_long_type_le() {
let blob = build_exif_with_long_orientation(8, false);
assert_eq!(parse_exif_orientation(&blob), Some(Orientation::Rotate270));
}

#[test]
fn with_exif_does_not_override_explicit_orientation() {
let blob = build_minimal_exif_with_orientation(6, false);
Expand Down
34 changes: 31 additions & 3 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,48 @@
//! All fields default to `None`, meaning the codec uses its own default.
//! `Some(true)` explicitly allows; `Some(false)` explicitly denies.
//!
//! # Choosing a starting point
//!
//! For **untrusted input** (network bytes, user uploads, third-party data),
//! prefer [`DecodePolicy::strict()`] as the starting point and selectively
//! re-enable features the application actually needs. This is the
//! recommended default for any service that processes bytes it did not
//! produce itself. Pair this with
//! [`ResourceLimits::for_untrusted_input`](crate::ResourceLimits::for_untrusted_input)
//! for resource caps.
//!
//! For **trusted input** (your own pipeline, internal tools), use
//! [`DecodePolicy::none()`] (all defaults) or [`DecodePolicy::permissive()`]
//! to keep all features available.
//!
//! # Named levels
//!
//! - [`DecodePolicy::strict()`] — **recommended for untrusted input.**
//! Minimal attack surface: no ICC/EXIF/XMP extraction, no progressive,
//! no animation, strict spec parsing, no truncated input.
//! - [`DecodePolicy::none()`] / [`EncodePolicy::none()`] — all defaults
//! - [`DecodePolicy::strict()`] — minimal attack surface (no metadata, no progressive, no animation)
//! - [`DecodePolicy::permissive()`] — allow everything
//! (each codec picks its own behavior).
//! - [`DecodePolicy::permissive()`] — allow everything (use only for
//! trusted input).
//!
//! Individual flags can be overridden after constructing a named level.
//! Individual flags can be overridden after constructing a named level
//! — e.g. `DecodePolicy::strict().with_allow_icc(true)` for strict-but-with-color.

/// Decode security policy.
///
/// Controls what features a decoder is permitted to use when processing
/// untrusted input. Codecs check these flags and skip or reject
/// accordingly; unrecognized flags are ignored.
///
/// # Recommended: start strict for untrusted input
///
/// When decoding bytes from the network, end users, or any third-party
/// source, use [`DecodePolicy::strict()`] as the starting point and
/// selectively enable the features the application actually needs.
/// `DecodePolicy::default()` returns [`DecodePolicy::none()`] (all
/// `None` — each codec's own default applies) for backwards compatibility,
/// but this is **not** the safest choice for untrusted input.
///
/// # Example
///
/// ```
Expand Down
Loading
Loading