Skip to content

Commit 51291ee

Browse files
committed
mirror_worker: bound gunzip output per run against zip bombs
GzipInflater::push wrote whole compressed chunks into the decoder and took the entire accumulated plaintext at once, so a small but highly compressible chunk could inflate into a Vec far larger than the isolate's memory ceiling. Feed the decoder in INFLATE_STEP slices, drain the sink per slice, and emit plaintext in <=INFLATE_STEP runs that gunzip queues and yields one per poll. Adds a zip-bomb test.
1 parent f58eb49 commit 51291ee

1 file changed

Lines changed: 94 additions & 23 deletions

File tree

crates/mirror_worker/src/body.rs

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ pub(crate) fn decoded_stream(
9797
Ok(stream)
9898
}
9999

100+
/// Largest compressed slice fed to the decoder per step, and the point at
101+
/// which accumulated plaintext is drained. DEFLATE can inflate a small
102+
/// input by a large factor (a hostile "zip bomb" reaches ~1000x), so a
103+
/// single unbounded `write_all` of a whole chunk could balloon the sink
104+
/// `Vec` far past the isolate's memory ceiling. Feeding the decoder in
105+
/// bounded slices and draining between them keeps the plaintext held at
106+
/// once proportional to this window, not to the compression ratio.
107+
const INFLATE_STEP: usize = 64 * 1024;
108+
100109
/// Incremental gzip inflater: feed compressed bytes with [`Self::push`]
101110
/// and drain the plaintext produced so far; call [`Self::finish`] once the
102111
/// compressed input ends to validate the gzip trailer (CRC-32 + ISIZE).
@@ -105,6 +114,11 @@ pub(crate) fn decoded_stream(
105114
/// compiles to and runs under WASM. `flate2::write::GzDecoder` handles all
106115
/// gzip framing: the 10-byte header, optional FNAME/FEXTRA/etc. fields
107116
/// (buffered across `push` calls if split across chunks), and the trailer.
117+
///
118+
/// [`Self::push`] feeds the decoder at most [`INFLATE_STEP`] compressed
119+
/// bytes at a time and drains after each step, so the sink never holds
120+
/// more than one step's worth of inflated output regardless of how large
121+
/// or compressible the caller's chunk is.
108122
struct GzipInflater {
109123
decoder: GzDecoder<Vec<u8>>,
110124
}
@@ -116,14 +130,28 @@ impl GzipInflater {
116130
}
117131
}
118132

119-
/// Feed one compressed chunk and return the plaintext bytes produced.
120-
/// May return an empty `Vec` if `input` only completed part of the
121-
/// gzip header or a DEFLATE block that hasn't emitted output yet.
122-
fn push(&mut self, input: &[u8]) -> Result<Vec<u8>> {
123-
self.decoder
124-
.write_all(input)
125-
.map_err(|e| Error::from(format!("gzip decode failed: {e}")))?;
126-
Ok(std::mem::take(self.decoder.get_mut()))
133+
/// Feed one compressed chunk, invoking `emit` with each bounded
134+
/// plaintext run produced. `emit` may be called zero times (the input
135+
/// only advanced the gzip header or a not-yet-emitting DEFLATE block),
136+
/// once, or many times for a highly compressible chunk.
137+
///
138+
/// Input is written to the decoder one [`INFLATE_STEP`] slice at a
139+
/// time, draining the sink after each, and every drained run is further
140+
/// split into at most [`INFLATE_STEP`]-byte emissions. So no single
141+
/// emitted run (what flows downstream) exceeds one step regardless of
142+
/// the (attacker-controlled) compression ratio, and the sink is drained
143+
/// per input step instead of accumulating the whole chunk's output.
144+
fn push(&mut self, input: &[u8], mut emit: impl FnMut(Vec<u8>)) -> Result<()> {
145+
for slice in input.chunks(INFLATE_STEP) {
146+
self.decoder
147+
.write_all(slice)
148+
.map_err(|e| Error::from(format!("gzip decode failed: {e}")))?;
149+
let produced = std::mem::take(self.decoder.get_mut());
150+
for run in produced.chunks(INFLATE_STEP) {
151+
emit(run.to_vec());
152+
}
153+
}
154+
Ok(())
127155
}
128156

129157
/// Finish decompression, returning any trailing plaintext. Errors if
@@ -140,48 +168,58 @@ impl GzipInflater {
140168
/// gunzipped plaintext chunks.
141169
///
142170
/// The returned stream inflates lazily: each poll pulls compressed chunks
143-
/// from `inner` until it can emit at least one plaintext byte, so memory
171+
/// from `inner` until it can emit at least one plaintext run, so memory
144172
/// use stays bounded by the chunk size rather than the whole body. A
145-
/// decode error (malformed gzip) or a truncated stream surfaces as a
146-
/// terminal `Err` item, after which the stream ends.
173+
/// single compressed chunk that inflates a lot is emitted as several
174+
/// [`INFLATE_STEP`]-bounded runs, drained from `pending` across polls, so
175+
/// a hostile compression ratio cannot force one giant allocation. A decode
176+
/// error (malformed gzip) or a truncated stream surfaces as a terminal
177+
/// `Err` item, after which the stream ends.
147178
pub(crate) fn gunzip<S>(inner: S) -> BodyStream
148179
where
149180
S: Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin + 'static,
150181
{
151182
struct DecodeState<S> {
152183
inner: S,
153184
inflater: Option<GzipInflater>,
185+
/// Plaintext runs decoded from the last compressed chunk but not
186+
/// yet emitted, drained one per poll (front to back).
187+
pending: std::collections::VecDeque<Vec<u8>>,
154188
done: bool,
155189
}
156190

157191
Box::pin(futures_util::stream::unfold(
158192
DecodeState {
159193
inner,
160194
inflater: Some(GzipInflater::new()),
195+
pending: std::collections::VecDeque::new(),
161196
done: false,
162197
},
163198
|mut st| async move {
199+
if let Some(run) = st.pending.pop_front() {
200+
return Some((Ok(run), st));
201+
}
164202
if st.done {
165203
return None;
166204
}
167205
loop {
168206
match st.inner.next().await {
169207
Some(Ok(chunk)) => {
170-
let out = match st.inflater.as_mut().expect("inflater present").push(&chunk)
171-
{
172-
Ok(out) => out,
173-
Err(e) => {
174-
st.done = true;
175-
return Some((Err(BodyError::Decode(e.to_string())), st));
176-
}
177-
};
208+
let inflater = st.inflater.as_mut().expect("inflater present");
209+
let mut runs = std::collections::VecDeque::new();
210+
if let Err(e) = inflater.push(&chunk, |run| runs.push_back(run)) {
211+
st.done = true;
212+
return Some((Err(BodyError::Decode(e.to_string())), st));
213+
}
178214
// A chunk may not yet yield any plaintext (partial
179215
// header / block); pull more instead of emitting an
180-
// empty item.
181-
if out.is_empty() {
216+
// empty item. Otherwise emit the first run now and
217+
// queue the rest for subsequent polls.
218+
let Some(first) = runs.pop_front() else {
182219
continue;
183-
}
184-
return Some((Ok(out), st));
220+
};
221+
st.pending = runs;
222+
return Some((Ok(first), st));
185223
}
186224
Some(Err(e)) => {
187225
st.done = true;
@@ -291,6 +329,39 @@ mod tests {
291329
);
292330
}
293331

332+
// A highly compressible payload whose plaintext far exceeds
333+
// INFLATE_STEP, delivered as a single compressed chunk, must inflate
334+
// to the exact original but be emitted as several bounded runs so no
335+
// single allocation exceeds the step. This is the zip-bomb guard.
336+
#[tokio::test]
337+
async fn large_ratio_chunk_emits_bounded_runs() {
338+
let plain = vec![0u8; INFLATE_STEP * 10 + 123];
339+
let compressed = gzip(&plain);
340+
assert!(
341+
compressed.len() < INFLATE_STEP,
342+
"test payload should compress to well under one step"
343+
);
344+
// Feed the whole compressed body as one chunk.
345+
let mut s = gunzip(stream::iter(vec![Ok::<_, BodyError>(compressed)]));
346+
let mut total = 0usize;
347+
let mut runs = 0usize;
348+
while let Some(item) = s.next().await {
349+
let run = item.unwrap();
350+
assert!(
351+
run.len() <= INFLATE_STEP,
352+
"run of {} bytes exceeds INFLATE_STEP {INFLATE_STEP}",
353+
run.len()
354+
);
355+
total += run.len();
356+
runs += 1;
357+
}
358+
assert_eq!(total, plain.len());
359+
assert!(
360+
runs > 1,
361+
"a >step payload must span multiple runs, got {runs}"
362+
);
363+
}
364+
294365
#[tokio::test]
295366
async fn upstream_error_propagates() {
296367
let compressed = gzip(b"partial");

0 commit comments

Comments
 (0)