Skip to main content

unparser_shared/
inflate.rs

1//! Reading compressed data from an untrusted file without letting it expand without bound.
2//!
3//! A few kilobytes of DEFLATE can stand for gigabytes of zeros — a "zip bomb". Read to the end,
4//! one entry of it allocates until the process is killed: not an error a caller can handle,
5//! an abort. [`InflateGuard`] wraps the decompressing reader of one entry or stream and fails it
6//! with an [`io::Error`] once the data expands past [`InflateLimits`]:
7//!
8//! - past a grace size of output, the output may be at most a fixed multiple of the compressed
9//!   size — what real documents compress to, with room to spare;
10//! - nothing expands past a maximum size.
11//!
12//! The limits differ by format, so each comes from that format's reference reader.
13//! [`InflateLimits::POI`] — Apache POI's `ZipSecureFile`, the reference reader for OOXML — is the
14//! default: past 100 KiB, at most 100 times the compressed size, and at most 4 GiB. Text and
15//! markup compress 3–20 times, pictures barely, and nothing a document's parts hold compresses a
16//! hundredfold. PDF streams do (a blank 1-bit image mask), and a PDF reader gives them its own
17//! limits.
18//!
19//! POI measures the compressed bytes consumed so far; this measures the entry's whole
20//! compressed size, which a reader knows from the ZIP directory or the stream's length. The
21//! ratio is therefore never smaller here than POI's, so nothing POI accepts is refused.
22//!
23//! A decoder that is not a [`Read`] (one that builds its output in a loop) applies the same rule
24//! with [`InflateLimits::check`]. [`is_inflation_limit`] tells the error apart from an I/O
25//! error, so a caller can classify it (an archive error, not a failing disk).
26//!
27//! ```
28//! use std::io::Read;
29//! use unparser_shared::inflate::{is_inflation_limit, InflateGuard};
30//!
31//! // 1 MiB of output claimed by a 1 KiB entry: past 100 KiB it is a bomb.
32//! let bomb = std::io::repeat(0).take(1 << 20);
33//! let err = InflateGuard::new(bomb, 1024).read_to_end(&mut Vec::new()).unwrap_err();
34//! assert!(is_inflation_limit(&err));
35//!
36//! // The same output from 64 KiB of input is an ordinary 16-fold compression.
37//! let text = std::io::repeat(b'a').take(1 << 20);
38//! assert_eq!(InflateGuard::new(text, 64 * 1024).read_to_end(&mut Vec::new()).unwrap(), 1 << 20);
39//! ```
40
41use std::fmt;
42use std::io::{self, Read};
43
44/// How far compressed data may expand before it is taken for a decompression bomb.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct InflateLimits {
47    /// The output up to which the ratio is not checked: a small output cannot exhaust memory
48    /// however well it compresses (a part of zeros, a blank picture).
49    pub grace_size: u64,
50    /// Past [`Self::grace_size`], the most the output may be as a multiple of the compressed
51    /// size.
52    pub max_expansion: u64,
53    /// The most the output may be at all.
54    pub max_size: u64,
55}
56
57impl InflateLimits {
58    /// Apache POI's `ZipSecureFile` defaults: `GRACE_ENTRY_SIZE` 100 KiB, `MIN_INFLATE_RATIO`
59    /// 0.01 (at most 100 times), `MAX_ENTRY_SIZE` 4 GiB (the 32-bit ZIP limit).
60    pub const POI: Self = Self {
61        grace_size: 100 * 1024,
62        max_expansion: 100,
63        max_size: 0xFFFF_FFFF,
64    };
65
66    /// Whether `inflated` bytes of output from `compressed` bytes stay within these limits.
67    pub fn allows(&self, compressed: u64, inflated: u64) -> bool {
68        inflated <= self.max_size
69            && (inflated <= self.grace_size
70                || inflated <= compressed.saturating_mul(self.max_expansion))
71    }
72
73    /// [`Self::allows`] as a result: the error [`InflateGuard`] fails with when it does not.
74    pub fn check(&self, compressed: u64, inflated: u64) -> io::Result<()> {
75        if self.allows(compressed, inflated) {
76            Ok(())
77        } else {
78            Err(io::Error::new(
79                io::ErrorKind::InvalidData,
80                InflationLimit {
81                    compressed,
82                    inflated,
83                },
84            ))
85        }
86    }
87}
88
89impl Default for InflateLimits {
90    fn default() -> Self {
91        Self::POI
92    }
93}
94
95/// A reader of decompressed data that fails once the data expands past its [`InflateLimits`].
96#[derive(Debug)]
97pub struct InflateGuard<R> {
98    inner: R,
99    compressed: u64,
100    inflated: u64,
101    limits: InflateLimits,
102}
103
104impl<R> InflateGuard<R> {
105    /// Guard `inner`, the decompressing reader of data `compressed_size` bytes long as stored,
106    /// with [`InflateLimits::POI`].
107    pub fn new(inner: R, compressed_size: u64) -> Self {
108        Self::with_limits(inner, compressed_size, InflateLimits::POI)
109    }
110
111    /// Guard `inner` with `limits`.
112    pub fn with_limits(inner: R, compressed_size: u64, limits: InflateLimits) -> Self {
113        Self {
114            inner,
115            compressed: compressed_size,
116            inflated: 0,
117            limits,
118        }
119    }
120}
121
122impl<R: Read> Read for InflateGuard<R> {
123    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
124        let n = self.inner.read(buf)?;
125        self.inflated += n as u64;
126        self.limits.check(self.compressed, self.inflated)?;
127        Ok(n)
128    }
129}
130
131/// The error [`InflateGuard`] fails with.
132#[derive(Debug)]
133pub struct InflationLimit {
134    compressed: u64,
135    inflated: u64,
136}
137
138impl fmt::Display for InflationLimit {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(
141            f,
142            "{} compressed bytes expanded past {} bytes: more than a document's data expands \
143             (a decompression bomb)",
144            self.compressed, self.inflated
145        )
146    }
147}
148
149impl std::error::Error for InflationLimit {}
150
151/// Whether `error` is [`InflateGuard`]'s — the data expanded past the limits — rather than a
152/// failure of the reader underneath.
153pub fn is_inflation_limit(error: &io::Error) -> bool {
154    error
155        .get_ref()
156        .is_some_and(|inner| inner.is::<InflationLimit>())
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn read_all(data: impl Read, compressed: u64) -> io::Result<usize> {
164        InflateGuard::new(data, compressed).read_to_end(&mut Vec::new())
165    }
166
167    #[test]
168    fn small_output_is_never_refused() {
169        let grace = InflateLimits::POI.grace_size;
170        assert_eq!(
171            read_all(io::repeat(0).take(grace), 1).unwrap(),
172            grace as usize
173        );
174    }
175
176    #[test]
177    fn other_limits_move_both_bounds() {
178        let limits = InflateLimits {
179            grace_size: 1 << 20,
180            max_expansion: 600,
181            max_size: 8 << 20,
182        };
183        let read = |len: u64, compressed: u64| {
184            InflateGuard::with_limits(io::repeat(0).take(len), compressed, limits)
185                .read_to_end(&mut Vec::new())
186        };
187        // 500-fold past the grace: within 600.
188        assert!(read(5 << 20, (5 << 20) / 500).is_ok());
189        // 1000-fold: refused.
190        assert!(is_inflation_limit(
191            &read(5 << 20, (5 << 20) / 1000).unwrap_err()
192        ));
193        // Under the grace size any ratio passes; past the maximum nothing does.
194        assert!(read(1 << 20, 1).is_ok());
195        assert!(is_inflation_limit(&read(9 << 20, 9 << 20).unwrap_err()));
196    }
197
198    #[test]
199    fn check_applies_the_rule_without_a_reader() {
200        let poi = InflateLimits::default();
201        assert!(poi.check(1, poi.grace_size).is_ok());
202        let err = poi.check(1, poi.grace_size + 1).unwrap_err();
203        assert!(is_inflation_limit(&err));
204        assert!(poi.allows(u64::MAX, poi.max_size));
205        assert!(!poi.allows(u64::MAX, poi.max_size + 1));
206    }
207
208    #[test]
209    fn output_past_a_hundredfold_is_a_bomb() {
210        let err = read_all(io::repeat(0).take(10 << 20), 10 * 1024).unwrap_err();
211        assert!(is_inflation_limit(&err), "{err}");
212        assert!(err.to_string().contains("decompression bomb"), "{err}");
213    }
214
215    #[test]
216    fn ordinary_compression_passes() {
217        // 20-fold: markup compresses about this well.
218        assert_eq!(
219            read_all(io::repeat(b'x').take(4 << 20), (4 << 20) / 20).unwrap(),
220            4 << 20
221        );
222    }
223
224    #[test]
225    fn an_io_error_underneath_is_not_the_limit() {
226        struct Failing;
227        impl Read for Failing {
228            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
229                Err(io::Error::other("disk"))
230            }
231        }
232        let err = read_all(Failing, 10).unwrap_err();
233        assert!(!is_inflation_limit(&err));
234    }
235}