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}