Repository navigation
v0.2.0 — soundness fixes + Limits API
⚠️ YANKED — this version has been pulled from crates.io. It contains a use-after-free inStreamingDecoder::update(fixed in 0.3.4) plus other soundness issues fixed across the 0.2.x / 0.3.x series. Use 0.3.4 or later — or, recommended for new projects, switch tozenwebp, which is#![forbid(unsafe_code)]and has no FFI exposure.
Security release: closes a use-after-free, two write-through-shared-borrow paths, an out-of-bounds read, and a class of length-validation gaps in the FFI layer over libwebp. Adds an opt-in Limits resource policy for callers handling untrusted input.
0.2.0 has been yanked from crates.io. A second-pass audit after release found an additional OOB class (caller-supplied strides cast to
i32for libwebp without an upper-bound check); see v0.2.1. Upgrade to 0.2.1.
Want pure-Rust WebP? For new projects,
zenwebpis the recommended choice — a pure-Rust port of libwebp built with#![forbid(unsafe_code)], with full feature parity, comparable runtime performance and compression, and no FFI surface area.
Versions 0.1.0–0.1.4 are yanked from crates.io. Users on those versions should upgrade.
webpx is intended to decode WebP images supplied by users (HTTP request bodies, uploaded files, etc.), so the affected code is reachable from untrusted input.
Limits — for untrusted input
use webpx::{Decoder, DecoderConfig, Limits};
let limits = Limits::none()
.with_max_pixels(64 * 1024 * 1024) // 64 MP per frame
.with_max_total_pixels(256 * 1024 * 1024) // 256 MP cumulative across animation frames
.with_max_frames(1024)
.with_max_input_bytes(64 * 1024 * 1024)
.with_max_metadata_bytes(4 * 1024 * 1024);
let img = Decoder::new(webp_data)?
.config(DecoderConfig::new().limits(limits))
.decode_rgba()?;The same Limits value also wires through AnimationDecoder::with_options_limits and mux::get_{icc_profile,exif,xmp}_with_limits. Field naming matches zencodec::ResourceLimits. See the Limits rustdoc for the full per-field enforcement matrix; see examples/decode_with_limits.rs for end-to-end usage.
Vulnerabilities
Issues #1, #2, #3, and #6 below are the four reproducers from Shnatsel's report. Issues #4, #5, #7, and the items in #8 surfaced while reviewing the rest of the FFI surface in response to the report.
1. Use-after-free in StreamingDecoder::with_buffer (HIGH)
fn with_buffer(output_buffer: &mut [u8], …) -> Result<Self> returned a StreamingDecoder whose internal raw pointer was derived from output_buffer.as_mut_ptr(). Because Self had no lifetime parameter, the borrow checker did not stop the caller from dropping output_buffer before the decoder. Subsequent append / update / get_partial / finish calls then wrote into freed memory.
Reproducer (compiled cleanly against 0.1.4):
let mut buffer = vec![0u8; 1024 * 1024];
let mut decoder = StreamingDecoder::with_buffer(&mut buffer, 1024, ColorMode::Rgba)?;
drop(buffer); // free the buffer
let _ = decoder.append(&data); // libwebp writes into freed memoryFix: StreamingDecoder now carries a lifetime parameter (StreamingDecoder<'a>); with_buffer returns Self<'a> tied to the buffer's borrow. ::new() returns StreamingDecoder<'static>, so call sites that don't use with_buffer compile unchanged.
2. Write through shared &[u32] in Encoder::new_argb (HIGH)
The zero-copy ARGB encoder path set picture.argb = data.as_ptr() as *mut u32 where data: &[u32] was a shared borrow from the caller. With the default EncoderConfig.exact == false, libwebp's WebPCleanupTransparentArea (lossy path, picture_tools_enc.c:104) and WebPReplaceTransparentPixels (lossless path, picture_tools_enc.c:91) call FlattenARGB / WebPAlphaReplace against pic->argb, mutating the caller's buffer.
This violates Rust's aliasing model: the caller holds a &[u32], but the encoder writes to that memory.
Trigger: Any call to Encoder::new_argb(...).encode(...) where the input contains transparent pixels (alpha = 0) and EncoderConfig.exact is the default false.
Fix: Encoder::new_argb and Encoder::new_argb_stride unconditionally set webp_config.exact = 1 before passing the picture to libwebp, so the cleanup paths are skipped. EncoderConfig.exact remains a builder method but cannot be turned off for the zero-copy ARGB path. Documented in the rustdoc.
3. Write through shared YUV planes in Encoder::new_yuv with alpha (MEDIUM)
Same shape as #2 for picture.y/u/v = planes.{y,u,v}.as_ptr() as *mut u8. When the supplied YuvPlanesRef carried an alpha plane (i.e., colorspace WEBP_YUV420A) and config.exact == false, libwebp's WebPCleanupTransparentArea YUV branch (picture_tools_enc.c:128-167) wrote into the borrowed Y/U/V planes via Flatten().
Fix: Same as #2 — webp_config.exact = 1 is forced when YuvPlanesRef carries an alpha plane.
4. Out-of-bounds read in AnimationDecoder::next_frame (HIGH, latent)
next_frame hard-coded the per-frame buffer size as width * height * 4, regardless of the configured ColorMode. For 3-bpp modes (Rgb, Bgr) the actual libwebp-allocated buffer is width * height * 3 bytes; the over-read was ~33 % past the end.
This was latent because libwebp's WebPAnimDecoder rejects every mode other than MODE_RGBA / MODE_BGRA at construction time, so the 3-bpp paths were unreachable in practice. 0.2.0 rejects the unsupported modes up front (with a clear error) and derives the bpp from the configured mode for forward compatibility.
5. MaybeUninit::assume_init on possibly-uninitialized memory (LOW)
ImageInfo::from_webp called MaybeUninit::<WebPBitstreamFeatures>::assume_init() unconditionally and before checking the WebPGetFeatures status code. On a malformed bitstream where libwebp returned an error without populating the struct, this was technically UB even though the recovered value was never read.
Fix: Status check moved before assume_init.
6. Missing length validation on YuvPlanesRef planes (HIGH)
Encoder::new_yuv passed planes.{y,u,v,a}.as_ptr() to libwebp with the caller-supplied strides, but never checked that each slice was at least stride × rows long. A short Y/U/V/A plane caused libwebp to read past the end of the buffer. The struct's fields are all pub, so a caller could construct an undersized YuvPlanesRef directly and pass it to the encoder.
A closely related issue (planes.u_stride != planes.v_stride silently ignored — only u_stride was used for both U and V, so a v_stride larger than u_stride would let libwebp read past the V plane) was discovered while writing the validator and is fixed by the same function.
Fix: New internal validate_yuv_planes() checks plane lengths against stride × rows, requires u_stride == v_stride, checks per-plane stride floors, and is called in both encode paths before the FFI pointer-cast.
7. Integer overflow on 32-bit usize (i686) (MEDIUM)
Several sites computed stride × height (or width × height × bpp) using plain * on values derived from user input. On i686 (usize is 32 bits) the product wraps before the length check, allowing a malicious caller with carefully chosen stride_pixels and height to bypass the buffer-size guard and trigger an out-of-bounds read in libwebp.
Affected sites:
encode.rs:834,encode.rs:1134— ARGBmin_lencheckdecode.rs:270—decode_intorequired_pixelscheck
Fix: All such sites use saturating_mul. The CI matrix exercises i686-unknown-linux-gnu via cross, and 0.2.0's regression tests include the saturating math.
8. Other defensive hardening (informational)
0.2.0 also includes a number of defensive fixes that aren't reachable under any input we identified but that close fragile patterns:
decode_advancedcallsWebPFreeDecBufferdefensively on the error return.decode_advancedusesdec_config.output.u.RGBA.sizefor the output slice instead of recomputingwidth × height × bpp.decode_yuvrejects negativey_stride/uv_stride/height/uv_heightfrom libwebp before computing plane sizes.StreamingDecoder::get_partialand::finishreject negative strides / dimensions returned by libwebp soas usizecannot wrap.decode_intorejects strides that don't fit in libwebp'si32stride parameter.AnimationDecoder::decode_allusessaturating_subfor per-frame duration computation, preventing wraparound on hostile non-monotonic timestamps.AnimationDecoder::decode_allcaps initialVec::with_capacityat 4096 frames, bounding attacker-controlled allocation.mux::get_icc_profile/get_exif/get_xmpreject ICCP/EXIF/XMP chunks larger than 256 MiB internally.YuvPlanes::newvalidates dimensions;YuvPlanes::new_checkedreturnsOption<Self>instead of panicking.
Other changes
Encoder::new_yuvnow validatesYuvPlanesRefplane lengths againststride × rows, requiresu_stride == v_stride(libwebp uses a singleuv_stride), and saturates strides pasti32::MAX.EncoderConfig,DecoderConfig,error::EncodingError,error::DecodingError,error::MuxError, andtypes::ImageInfoare now#[non_exhaustive].- Crate now compiles under every feature combination (
--no-default-featureswith any subset ofdecode/encode/std/streaming/animation/icc). Closes #1.
Breaking changes
StreamingDecoderhas a lifetime parameter (StreamingDecoder<'a>).AnimationDecoder::with_optionsrejects unsupported color modes (Rgb,Bgr,Argb) up front instead of failing inside libwebp.Encoder::new_yuvrejects malformedYuvPlanesRef(short planes / mismatched UV strides) that previously triggered libwebp out-of-bounds reads.AnimationDecoder::decode_allcaps initialVec::with_capacityat 4096 frames.EncoderConfig/DecoderConfig/Error*enums andImageInfoare#[non_exhaustive].
Credit
Sergey "Shnatsel" Davidoff for reporting issues #1, #2, #3, and #6 with reproducer tests.
Regression tests
Shnatsel's reproducers (plus a borrow-checker demonstration for issue #1) are committed at tests/soundness.rs. They run under both ordinary cargo test and under nightly + AddressSanitizer in the Fuzz & Sanitizers CI workflow.
Disclosure timeline
All times Mountain Daylight Time (UTC−6).
- 2026-05-01 11:13 — Shnatsel opens issue #2 asking how to report.
- 2026-05-01 16:26 — Maintainer replies with private contact (
lilith@imazen.io). - 2026-05-01 16:52 — Shnatsel acknowledges, says report incoming.
- 2026-05-01 19:38 —
0.2.0published to crates.io with the four reported issues fixed and Shnatsel's reproducers committed verbatim as regression tests; additional FFI-surface fixes identified during review of the surrounding code shipped in the same release. - 2026-05-01 (later) —
0.1.0through0.1.4yanked from crates.io.
See CHANGELOG.md for the full diff summary.