Skip to main content

vector_common/
decompression.rs

1//! Shared decompression limits used to prevent decompression-bomb (`DoS`) attacks.
2//!
3//! A length or compressed payload read from an untrusted peer must never drive an unbounded
4//! in-memory allocation. This module owns the global decompressed-size cap and the helpers that
5//! enforce it, so every source and codec that decompresses untrusted input shares a single,
6//! consistently-configured limit.
7//!
8//! # Usage
9//!
10//! Wrap any decompression at an untrusted boundary with the appropriate [`CappedDecoder`]
11//! constructor and call [`CappedDecoder::decompress`]:
12//!
13//! ```rust,ignore
14//! let data = CappedDecoder::gzip(reader).decompress()?;
15//! let data = CappedDecoder::zlib(reader).decompress()?;
16//! let data = CappedDecoder::zstd(reader)?.decompress()?;
17//! ```
18//!
19//! The constructors enforce the global decompressed-size cap so that a compression bomb cannot
20//! drive unbounded allocation.
21
22// Raw decoder types (flate2 / zstd) are only allowed in this module, which wraps them safely.
23#![expect(
24    clippy::disallowed_types,
25    reason = "this module implements CappedDecoder, the safe wrapper around raw decoders; raw types may only appear here"
26)]
27
28use std::{
29    fmt,
30    io::{self, Read},
31    sync::OnceLock,
32};
33
34use flate2::read::{MultiGzDecoder, ZlibDecoder};
35
36/// Default cap on the size of any decompressed payload.
37///
38/// Prevents a compressed "bomb" from causing unbounded memory growth.
39pub const DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES: usize = 100 * 1024 * 1024;
40
41static MAX_DECOMPRESSED_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
42static MAX_ZLIB_COMPRESSED_FRAME_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
43static MAX_ZSTD_WINDOW_LOG: OnceLock<Option<u32>> = OnceLock::new();
44
45/// Maps a decompressed cap to the largest compressed frame that can legitimately produce output
46/// within it, using zlib's worst-case expansion of 13.5% + 11 bytes. This lets us reject an
47/// oversized declared payload before buffering it, without rejecting a valid frame whose
48/// decompressed content stays within the decompressed cap.
49///
50/// See <https://zlib.net/zlib_tech.html> ("the worst case ... can result in an expansion of at
51/// most 13.5%, plus eleven bytes").
52#[allow(clippy::cast_possible_truncation)] // limit derives from a usize; saturating math keeps it in range
53const fn zlib_compressed_frame_limit(decompressed_limit: usize) -> usize {
54    (decompressed_limit as u64)
55        .saturating_mul(1135)
56        .saturating_div(1000)
57        .saturating_add(11) as usize
58}
59
60const DEFAULT_MAX_ZLIB_COMPRESSED_FRAME_SIZE_BYTES: usize =
61    zlib_compressed_frame_limit(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES);
62
63const DEFAULT_MAX_ZSTD_WINDOW_LOG: Option<u32> =
64    zstd_window_log_max(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES);
65
66/// Override the global decompressed payload size cap. Must be called before any sources start.
67///
68/// # Panics
69///
70/// Panics if called more than once, as the global cap may only be initialized a single time.
71pub fn set_max_decompressed_size_bytes(size: usize) {
72    MAX_DECOMPRESSED_SIZE_BYTES
73        .set(size)
74        .expect("max_decompressed_size_bytes already set");
75    MAX_ZLIB_COMPRESSED_FRAME_SIZE_BYTES
76        .set(zlib_compressed_frame_limit(size))
77        .expect("max_zlib_compressed_frame_size_bytes already set");
78    MAX_ZSTD_WINDOW_LOG
79        .set(zstd_window_log_max(size))
80        .expect("max_zstd_window_log already set");
81}
82
83/// Returns the currently configured decompressed payload size cap.
84pub fn max_decompressed_size_bytes() -> usize {
85    *MAX_DECOMPRESSED_SIZE_BYTES
86        .get()
87        .unwrap_or(&DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES)
88}
89
90/// Returns the maximum compressed frame wire size we are willing to buffer, derived from the
91/// decompressed cap plus zlib's worst-case expansion. See `zlib_compressed_frame_limit`.
92pub fn max_zlib_compressed_frame_size_bytes() -> usize {
93    *MAX_ZLIB_COMPRESSED_FRAME_SIZE_BYTES
94        .get()
95        .unwrap_or(&DEFAULT_MAX_ZLIB_COMPRESSED_FRAME_SIZE_BYTES)
96}
97
98/// Smallest zstd `window_log_max` capable of representing `max_decompressed_size` bytes.
99///
100/// zstd frames declare a window size that the decoder must allocate up front; a crafted frame can
101/// request a multi-gigabyte window even though its output would later trip the decompressed-size
102/// cap. Clamping the decoder's `window_log_max` to the smallest power-of-two window that can still
103/// hold a legitimate payload bounds that allocation. A zero cap maps to the minimum window log
104/// (not `None`) so the guard stays at its strictest rather than being disabled.
105///
106/// This is protocol-neutral: the ceiling is derived from the decompressed cap so any transport's
107/// frames decode as long as their window fits the cap. Transports that impose a tighter,
108/// spec-mandated window (HTTP `Content-Encoding: zstd`, see [`http_zstd_window_log_max`]) apply
109/// that on top.
110#[must_use]
111#[allow(clippy::manual_clamp)] // `usize::clamp` is not a const fn; the manual form keeps this const
112pub const fn zstd_window_log_max(max_decompressed_size: usize) -> Option<u32> {
113    const MIN_ZSTD_WINDOW_LOG: u32 = 10;
114    const MAX_ZSTD_WINDOW_LOG: u32 = 31;
115
116    // `window_log_max` is expressed as a power-of-two log. Use the smallest zstd window capable of
117    // representing the configured byte budget.
118    match max_decompressed_size.checked_sub(1) {
119        // A zero cap has no representable window; fall back to the smallest window rather than
120        // leaving the allocation guard unset.
121        None => Some(MIN_ZSTD_WINDOW_LOG),
122        Some(max_index) => {
123            let window_log = usize::BITS - max_index.leading_zeros();
124            let clamped = if window_log < MIN_ZSTD_WINDOW_LOG {
125                MIN_ZSTD_WINDOW_LOG
126            } else if window_log > MAX_ZSTD_WINDOW_LOG {
127                MAX_ZSTD_WINDOW_LOG
128            } else {
129                window_log
130            };
131            Some(clamped)
132        }
133    }
134}
135
136/// RFC 9659 window ceiling for zstd under HTTP `Content-Encoding: zstd`: conformant senders require
137/// a `Window_Size` of at most 8 MB (2^23) and decoders need only support up to that. This bounds
138/// the decoder's window allocation to 8 MB regardless of the (much larger) decompressed cap. It
139/// governs HTTP content coding only; other transports (e.g. gRPC/OTLP, whose clients are not bound
140/// by RFC 9659 and may legitimately use larger windows) are not clamped to it.
141/// See <https://www.rfc-editor.org/info/rfc9659/>.
142pub const HTTP_ZSTD_WINDOW_LOG_MAX: u32 = 23;
143
144/// Like [`zstd_window_log_max`] but additionally clamped to the RFC 9659 HTTP window ceiling
145/// ([`HTTP_ZSTD_WINDOW_LOG_MAX`]). Use for HTTP `Content-Encoding: zstd`; use the protocol-neutral
146/// [`zstd_window_log_max`] for transports RFC 9659 does not govern.
147#[must_use]
148pub fn http_zstd_window_log_max(max_decompressed_size: usize) -> Option<u32> {
149    zstd_window_log_max(max_decompressed_size).map(|w| w.min(HTTP_ZSTD_WINDOW_LOG_MAX))
150}
151
152/// Returns the zstd `window_log_max` derived from the global decompressed cap
153/// ([`max_decompressed_size_bytes`]).
154///
155/// Convenience getter for the common case where the decoder window should track the global cap;
156/// use [`zstd_window_log_max`] directly when enforcing an explicit, non-global limit (e.g. the
157/// HTTP body decompressor's per-call limit).
158#[must_use]
159pub fn max_zstd_window_log() -> Option<u32> {
160    MAX_ZSTD_WINDOW_LOG
161        .get()
162        .copied()
163        .unwrap_or(DEFAULT_MAX_ZSTD_WINDOW_LOG)
164}
165
166/// Error raised when a decompressed payload would exceed the configured size cap.
167///
168/// Surfaced (wrapped in [`io::Error`]) by [`CappedDecoder::decompress`] and the [`CappedReader`]
169/// returned by [`CappedDecoder::into_reader`]. Use [`is_decompressed_size_limit_error`] to detect
170/// it and distinguish an oversized-input fault from an unrelated I/O error.
171#[derive(Debug)]
172pub struct DecompressedSizeLimitExceeded;
173
174impl fmt::Display for DecompressedSizeLimitExceeded {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.write_str("decompressed size exceeds the configured limit")
177    }
178}
179
180impl std::error::Error for DecompressedSizeLimitExceeded {}
181
182/// Returns whether `error` was raised because decompression hit the size cap (see
183/// [`DecompressedSizeLimitExceeded`]).
184#[must_use]
185pub fn is_decompressed_size_limit_error(error: &io::Error) -> bool {
186    fn is_marker(source: &(dyn std::error::Error + Send + Sync + 'static)) -> bool {
187        source.is::<DecompressedSizeLimitExceeded>()
188    }
189
190    error.get_ref().is_some_and(is_marker)
191}
192
193/// A size-capped decompression reader.
194///
195/// Wraps any `R: Read` (typically a raw decoder like `MultiGzDecoder` or `ZlibDecoder`) and
196/// enforces the configured decompressed-size cap so that a compression bomb cannot drive
197/// unbounded memory allocation.
198///
199/// Construct via the typed class methods ([`CappedDecoder::gzip`], [`CappedDecoder::zlib`],
200/// [`CappedDecoder::zstd`]) rather than by wrapping a raw decoder directly. Read the whole payload
201/// into memory with [`CappedDecoder::decompress`], or stream it through [`CappedDecoder::into_reader`].
202pub struct CappedDecoder<R: Read> {
203    inner: io::Take<R>,
204    limit: usize,
205}
206
207impl<R: Read> CappedDecoder<R> {
208    fn with_limit(reader: R, limit: usize) -> Self {
209        Self {
210            inner: reader.take((limit as u64).saturating_add(1)),
211            limit,
212        }
213    }
214
215    /// Reads all decompressed bytes into a `Vec`, returning an error if the output exceeds the
216    /// configured cap.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if reading from the underlying decoder fails, or
221    /// [`DecompressedSizeLimitExceeded`] if the decompressed output exceeds the cap.
222    pub fn decompress(self) -> io::Result<Vec<u8>> {
223        let mut buf = Vec::new();
224        self.into_reader().read_to_end(&mut buf)?;
225        Ok(buf)
226    }
227
228    /// Converts the decoder into a streaming [`CappedReader`] that enforces the cap as bytes are
229    /// read, rather than buffering the whole payload up front.
230    ///
231    /// Prefer this over consuming a raw decoder directly: the returned reader errors out (instead
232    /// of silently truncating) the moment the decompressed output would exceed the cap, so a
233    /// streaming consumer such as [`io::copy`], `serde_json::from_reader`, or `BufReader` cannot
234    /// process a truncated-but-valid-looking payload.
235    pub fn into_reader(self) -> CappedReader<R> {
236        CappedReader {
237            inner: self.inner,
238            limit: self.limit,
239            consumed: 0,
240        }
241    }
242}
243
244/// A streaming, size-capped decompression reader returned by [`CappedDecoder::into_reader`].
245///
246/// Yields decompressed bytes incrementally and returns a [`DecompressedSizeLimitExceeded`] error
247/// (wrapped in [`io::Error`]) as soon as the cumulative output would exceed the cap.
248pub struct CappedReader<R: Read> {
249    inner: io::Take<R>,
250    limit: usize,
251    consumed: usize,
252}
253
254impl<R: Read> Read for CappedReader<R> {
255    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
256        // The underlying reader is bounded one byte past the cap, so reading beyond `limit` is the
257        // unambiguous signal that the payload is oversized.
258        let n = self.inner.read(buf)?;
259        self.consumed = self.consumed.saturating_add(n);
260        if self.consumed > self.limit {
261            return Err(io::Error::other(DecompressedSizeLimitExceeded));
262        }
263        Ok(n)
264    }
265}
266
267impl<S: Read> CappedDecoder<MultiGzDecoder<S>> {
268    /// Creates a capped gzip decoder using the global decompressed-size cap.
269    pub fn gzip(reader: S) -> Self {
270        Self::gzip_with_limit(reader, max_decompressed_size_bytes())
271    }
272
273    /// Creates a capped gzip decoder using an explicit decompressed-size cap.
274    pub fn gzip_with_limit(reader: S, limit: usize) -> Self {
275        Self::with_limit(MultiGzDecoder::new(reader), limit)
276    }
277}
278
279impl<S: Read> CappedDecoder<ZlibDecoder<S>> {
280    /// Creates a capped zlib/deflate decoder using the global decompressed-size cap.
281    pub fn zlib(reader: S) -> Self {
282        Self::zlib_with_limit(reader, max_decompressed_size_bytes())
283    }
284
285    /// Creates a capped zlib/deflate decoder using an explicit decompressed-size cap.
286    pub fn zlib_with_limit(reader: S, limit: usize) -> Self {
287        Self::with_limit(ZlibDecoder::new(reader), limit)
288    }
289}
290
291impl<S: Read> CappedDecoder<zstd::stream::read::Decoder<'static, io::BufReader<S>>> {
292    /// Creates a capped zstd decoder using the global decompressed-size cap.
293    ///
294    /// Also constrains the decoder's internal window allocation via `window_log_max` so a crafted
295    /// frame cannot request a large window before the decompressed-size cap trips. The window is
296    /// derived from the cap only ([`zstd_window_log_max`]); for HTTP `Content-Encoding: zstd` use
297    /// [`zstd_http`](Self::zstd_http), which applies the tighter RFC 9659 ceiling.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header).
302    pub fn zstd(reader: S) -> io::Result<Self> {
303        Self::zstd_with_limit(reader, max_decompressed_size_bytes())
304    }
305
306    /// Creates a capped zstd decoder using an explicit decompressed-size cap, with the window
307    /// derived from that cap only ([`zstd_window_log_max`]).
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header).
312    pub fn zstd_with_limit(reader: S, limit: usize) -> io::Result<Self> {
313        Self::zstd_with_window_log(reader, limit, zstd_window_log_max(limit))
314    }
315
316    /// Creates a capped zstd decoder for HTTP `Content-Encoding: zstd` using the global
317    /// decompressed-size cap, clamping the decoder window to the RFC 9659 8 MB ceiling
318    /// ([`http_zstd_window_log_max`]).
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header).
323    pub fn zstd_http(reader: S) -> io::Result<Self> {
324        Self::zstd_http_with_limit(reader, max_decompressed_size_bytes())
325    }
326
327    /// Creates a capped zstd decoder for HTTP `Content-Encoding: zstd` using an explicit
328    /// decompressed-size cap, clamping the decoder window to the RFC 9659 8 MB ceiling
329    /// ([`http_zstd_window_log_max`]).
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header).
334    pub fn zstd_http_with_limit(reader: S, limit: usize) -> io::Result<Self> {
335        Self::zstd_with_window_log(reader, limit, http_zstd_window_log_max(limit))
336    }
337
338    fn zstd_with_window_log(
339        reader: S,
340        limit: usize,
341        window_log_max: Option<u32>,
342    ) -> io::Result<Self> {
343        let mut decoder = zstd::stream::read::Decoder::new(reader)?;
344        if let Some(window_log_max) = window_log_max {
345            decoder.window_log_max(window_log_max)?;
346        }
347        Ok(Self::with_limit(decoder, limit))
348    }
349}