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}