Skip to content

Commit e7a7b23

Browse files
committed
mirror_worker: bound gunzip input per step
INFLATE_STEP capped the compressed slice handed to write_all, but write_all inflates its whole slice into the decoder sink before returning, so a 64 KiB slice could produce ~64 MiB of plaintext at DEFLATE's ~1032x. Shrink the input step to 4 KiB, capping transient plaintext at ~4 MiB, and stop feeding once a step produces output, parking the rest of the chunk for the next poll. Downstream runs stay capped by a separate EMIT_STEP.
1 parent e44988c commit e7a7b23

1 file changed

Lines changed: 138 additions & 69 deletions

File tree

crates/mirror_worker/src/body.rs

Lines changed: 138 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -97,28 +97,33 @@ 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;
100+
/// Compressed bytes fed to the decoder per [`GzipInflater::step`] call.
101+
/// `write_all` inflates its whole slice into the decoder's sink before
102+
/// returning, and DEFLATE reaches ~1032x, so a step holds up to
103+
/// `INFLATE_INPUT_STEP * 1032` bytes of plaintext transiently (~4 MiB
104+
/// here). A small input step keeps that under the isolate's ~128 MB
105+
/// ceiling even for a zip bomb; a larger chunk or the whole body is never
106+
/// handed to `write_all` at once.
107+
const INFLATE_INPUT_STEP: usize = 4 * 1024;
108108

109-
/// Incremental gzip inflater: feed compressed bytes with [`Self::push`]
110-
/// and drain the plaintext produced so far; call [`Self::finish`] once the
111-
/// compressed input ends to validate the gzip trailer (CRC-32 + ISIZE).
109+
/// Largest plaintext run emitted downstream. The plaintext drained after a
110+
/// step is split into runs no larger than this.
111+
const EMIT_STEP: usize = 64 * 1024;
112+
113+
/// Incremental gzip inflater: feed a bounded compressed slice with
114+
/// [`Self::step`], draining the plaintext it produced; call
115+
/// [`Self::finish`] once the compressed input ends to validate the gzip
116+
/// trailer (CRC-32 + ISIZE).
112117
///
113118
/// Backed by [`flate2`]'s pure-Rust `rust_backend` (`miniz_oxide`), so it
114119
/// compiles to and runs under WASM. `flate2::write::GzDecoder` handles all
115120
/// gzip framing: the 10-byte header, optional FNAME/FEXTRA/etc. fields
116-
/// (buffered across `push` calls if split across chunks), and the trailer.
121+
/// (buffered across calls if split across steps), and the trailer.
117122
///
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.
123+
/// The caller (see [`gunzip`]) feeds at most [`INFLATE_INPUT_STEP`] bytes
124+
/// per [`Self::step`] and stops once a step yields plaintext, leaving the
125+
/// rest of the compressed chunk unfed. At most one step is inflated ahead
126+
/// of what has been emitted.
122127
struct GzipInflater {
123128
decoder: GzDecoder<Vec<u8>>,
124129
}
@@ -130,26 +135,24 @@ impl GzipInflater {
130135
}
131136
}
132137

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.
138+
/// Feed one compressed slice (at most [`INFLATE_INPUT_STEP`] bytes),
139+
/// invoking `emit` with each [`EMIT_STEP`]-bounded plaintext run the
140+
/// step produced. `emit` may be called zero times (the slice only
141+
/// advanced the gzip header or a not-yet-emitting DEFLATE block), once,
142+
/// or several times for a highly compressible slice.
137143
///
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-
}
144+
/// # Panics
145+
///
146+
/// Debug-asserts `slice.len() <= INFLATE_INPUT_STEP`; a larger slice
147+
/// breaks the transient-plaintext bound.
148+
fn step(&mut self, slice: &[u8], mut emit: impl FnMut(Vec<u8>)) -> Result<()> {
149+
debug_assert!(slice.len() <= INFLATE_INPUT_STEP, "input slice too large");
150+
self.decoder
151+
.write_all(slice)
152+
.map_err(|e| Error::from(format!("gzip decode failed: {e}")))?;
153+
let produced = std::mem::take(self.decoder.get_mut());
154+
for run in produced.chunks(EMIT_STEP) {
155+
emit(run.to_vec());
153156
}
154157
Ok(())
155158
}
@@ -167,23 +170,29 @@ impl GzipInflater {
167170
/// Wrap a compressed body `Stream` in a decoding stream that yields the
168171
/// gunzipped plaintext chunks.
169172
///
170-
/// The returned stream inflates lazily: each poll pulls compressed chunks
171-
/// from `inner` until it can emit at least one plaintext run, so memory
172-
/// use stays bounded by the chunk size rather than the whole body. A
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.
173+
/// Each poll feeds the decoder at most [`INFLATE_INPUT_STEP`] compressed
174+
/// bytes and stops once a step yields plaintext, leaving the rest of the
175+
/// current chunk unfed until the next poll (tracked by `current`/`pos`).
176+
/// At most one input step is inflated ahead of what has been emitted, so
177+
/// the transient plaintext is bounded by `INFLATE_INPUT_STEP * ratio`
178+
/// rather than the whole chunk's inflation. A step that produces more than
179+
/// [`EMIT_STEP`] of plaintext yields several bounded runs drained from
180+
/// `pending`. A decode error (malformed gzip) or a truncated stream
181+
/// surfaces as a terminal `Err` item, after which the stream ends.
178182
pub(crate) fn gunzip<S>(inner: S) -> BodyStream
179183
where
180184
S: Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin + 'static,
181185
{
182186
struct DecodeState<S> {
183187
inner: S,
184188
inflater: Option<GzipInflater>,
185-
/// Plaintext runs decoded from the last compressed chunk but not
186-
/// yet emitted, drained one per poll (front to back).
189+
/// Compressed chunk currently being fed, and the offset of the
190+
/// next unfed byte. Bytes at `pos..` are not fed until the current
191+
/// runs drain, capping how far ahead the decoder inflates.
192+
current: Vec<u8>,
193+
pos: usize,
194+
/// Plaintext runs from the last step not yet emitted, drained one
195+
/// per poll (front to back).
187196
pending: std::collections::VecDeque<Vec<u8>>,
188197
done: bool,
189198
}
@@ -192,6 +201,8 @@ where
192201
DecodeState {
193202
inner,
194203
inflater: Some(GzipInflater::new()),
204+
current: Vec::new(),
205+
pos: 0,
195206
pending: std::collections::VecDeque::new(),
196207
done: false,
197208
},
@@ -203,24 +214,30 @@ where
203214
return None;
204215
}
205216
loop {
206-
match st.inner.next().await {
207-
Some(Ok(chunk)) => {
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-
}
214-
// A chunk may not yet yield any plaintext (partial
215-
// header / block); pull more instead of emitting an
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 {
219-
continue;
220-
};
217+
// Feed the unfed tail of the current chunk one bounded
218+
// step at a time, stopping as soon as a step emits.
219+
while st.pos < st.current.len() {
220+
let end = (st.pos + INFLATE_INPUT_STEP).min(st.current.len());
221+
let slice = st.current[st.pos..end].to_vec();
222+
st.pos = end;
223+
let inflater = st.inflater.as_mut().expect("inflater present");
224+
let mut runs = std::collections::VecDeque::new();
225+
if let Err(e) = inflater.step(&slice, |run| runs.push_back(run)) {
226+
st.done = true;
227+
return Some((Err(BodyError::Decode(e.to_string())), st));
228+
}
229+
if let Some(first) = runs.pop_front() {
221230
st.pending = runs;
222231
return Some((Ok(first), st));
223232
}
233+
}
234+
235+
// Current chunk fully fed; pull the next compressed chunk.
236+
match st.inner.next().await {
237+
Some(Ok(chunk)) => {
238+
st.current = chunk;
239+
st.pos = 0;
240+
}
224241
Some(Err(e)) => {
225242
st.done = true;
226243
return Some((Err(e), st));
@@ -329,17 +346,16 @@ mod tests {
329346
);
330347
}
331348

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.
349+
// A compressible payload whose plaintext exceeds EMIT_STEP, delivered
350+
// as one compressed chunk, reconstructs and is emitted as
351+
// EMIT_STEP-bounded runs rather than a single unbounded one.
336352
#[tokio::test]
337353
async fn large_ratio_chunk_emits_bounded_runs() {
338-
let plain = vec![0u8; INFLATE_STEP * 10 + 123];
354+
let plain = vec![0u8; EMIT_STEP * 10 + 123];
339355
let compressed = gzip(&plain);
340356
assert!(
341-
compressed.len() < INFLATE_STEP,
342-
"test payload should compress to well under one step"
357+
compressed.len() < EMIT_STEP,
358+
"test payload should compress to well under one emit step"
343359
);
344360
// Feed the whole compressed body as one chunk.
345361
let mut s = gunzip(stream::iter(vec![Ok::<_, BodyError>(compressed)]));
@@ -348,8 +364,8 @@ mod tests {
348364
while let Some(item) = s.next().await {
349365
let run = item.unwrap();
350366
assert!(
351-
run.len() <= INFLATE_STEP,
352-
"run of {} bytes exceeds INFLATE_STEP {INFLATE_STEP}",
367+
run.len() <= EMIT_STEP,
368+
"run of {} bytes exceeds EMIT_STEP {EMIT_STEP}",
353369
run.len()
354370
);
355371
total += run.len();
@@ -362,6 +378,59 @@ mod tests {
362378
);
363379
}
364380

381+
// Feeding INFLATE_INPUT_STEP-bounded slices (as gunzip does) round-trips
382+
// a body whose compressed form spans several steps.
383+
#[test]
384+
fn inflater_step_bounds_input() {
385+
// A poorly-compressible body whose compressed form spans several
386+
// input steps.
387+
let mut plain = Vec::new();
388+
for i in 0u32..0x1_0000 {
389+
plain.extend_from_slice(&i.wrapping_mul(2_654_435_761).to_le_bytes());
390+
}
391+
let compressed = gzip(&plain);
392+
assert!(
393+
compressed.len() > INFLATE_INPUT_STEP,
394+
"test needs a body spanning multiple input steps"
395+
);
396+
397+
let mut inflater = GzipInflater::new();
398+
let mut total = 0usize;
399+
// Feed as gunzip does: INFLATE_INPUT_STEP-bounded slices.
400+
for slice in compressed.chunks(INFLATE_INPUT_STEP) {
401+
inflater
402+
.step(slice, |run| {
403+
assert!(run.len() <= EMIT_STEP, "run exceeds EMIT_STEP");
404+
total += run.len();
405+
})
406+
.unwrap();
407+
}
408+
total += inflater.finish().unwrap().len();
409+
assert_eq!(total, plain.len());
410+
}
411+
412+
// One compressed chunk whose inflation exceeds a step: the first poll
413+
// returns an EMIT_STEP-bounded run, leaving the rest unfed, and the
414+
// full stream reconstructs the original.
415+
#[tokio::test]
416+
async fn large_ratio_chunk_emits_before_full_feed() {
417+
let plain = vec![0u8; EMIT_STEP * 8];
418+
let compressed = gzip(&plain);
419+
let mut s = gunzip(stream::iter(vec![Ok::<_, BodyError>(compressed)]));
420+
let first = s.next().await.expect("a run").expect("no error");
421+
assert!(
422+
first.len() <= EMIT_STEP,
423+
"first emitted run must be bounded, got {}",
424+
first.len()
425+
);
426+
// The remaining runs complete the original.
427+
let mut total = first.len();
428+
while let Some(item) = s.next().await {
429+
total += item.unwrap().len();
430+
}
431+
assert_eq!(total, plain.len());
432+
}
433+
365434
#[tokio::test]
366435
async fn upstream_error_propagates() {
367436
let compressed = gzip(b"partial");

0 commit comments

Comments
 (0)