forked from 1b-agent/opencadstudio-upstream
fix(plot-style): cap CTB inflate output at 64 MiB
Bug: CTB plot-style tables are zlib-deflated, and decompress_ctb inflated them with a bare read_to_end - no output budget. A few hundred KB of crafted deflate input expands to gigabytes (A/space runs compress about 1000:1), and the Vec grows until the allocator aborts; an abort is not a catchable panic. Both the zlib path and the legacy raw-deflate fallback were unbounded. Reproduce: 1. Craft a CTB whose payload deflates from 128 MiB of spaces (tiny on disk), with a valid PIAFILEVERSION header or as legacy raw deflate. 2. Select it as the plot style (open/plot). 3. Before: RSS climbs past the limit, then "memory allocation failed". 4. After: the read stops at the 64 MiB limit and the load fails with "... inflated data exceeds the 67108864-byte limit". 5. Boundary: a payload inflating to exactly 64 MiB loads (the limit means "exceeds"); one byte more is refused. Fix: inflate_capped reads through take(MAX_INFLATED_BYTES + 1) and refuses when the count is greater than 64 MiB. The extra byte is what makes "exactly at the limit" distinguishable from "at least the limit": a bare take(cap) stops at the cap, so read == cap is ambiguous, and a >= cap comparison would wrongly refuse a legitimate boundary payload. All three inflation sites (zlib, raw-deflate fallback, legacy decoder) route through the helper; error strings are preserved. 64 MiB is far above any real plot-style table. Four tests: zlib bomb refused, legacy bomb refused, exactly-at-cap accepted, cap-plus-one refused.
This commit is contained in:
1 parent
a0308dfa88
commit
ac6aa750f6
1 file changed
+116
-12
+116
-12
@@ -454,7 +454,7 @@ impl PlotStyleTable {
|
||||
fn decompress_ctb(data: &[u8]) -> Result<(String, Vec<String>), String> {
|
||||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
let mut warnings = Vec::new();
|
||||
let mut decoded = Vec::new();
|
||||
let mut decoded;
|
||||
if data.starts_with(PREFIX) {
|
||||
if data.len() < 60 {
|
||||
return Err("CTB header is truncated".into());
|
||||
@@ -504,9 +504,10 @@ fn decompress_ctb(data: &[u8]) -> Result<(String, Vec<String>), String> {
|
||||
decoded = inflate_lenient(payload, &mut warnings)?;
|
||||
} else {
|
||||
use flate2::read::DeflateDecoder;
|
||||
DeflateDecoder::new(payload)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
|
||||
decoded = inflate_capped(
|
||||
DeflateDecoder::new(payload),
|
||||
"legacy CTB deflate decompress",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
while decoded.last() == Some(&0) {
|
||||
@@ -519,22 +520,46 @@ fn decompress_ctb(data: &[u8]) -> Result<(String, Vec<String>), String> {
|
||||
Ok((text, warnings))
|
||||
}
|
||||
|
||||
/// Far above any real plot-style table (a CTB is a text config file), so a
|
||||
/// crafted few-hundred-KB payload cannot inflate until memory runs out —
|
||||
/// flate2's readers have no output budget of their own. Same idea as the
|
||||
/// `STREAM_LIMIT` in `dgn8.rs`.
|
||||
const MAX_INFLATED_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// One decompression with a hard output budget: `read_to_end` grows its
|
||||
/// `Vec` without bound, so the reader is capped and a payload that reaches
|
||||
/// past the cap is refused — an allocation failure is an *abort*, not a
|
||||
/// catchable panic. The reader is allowed one byte past the cap:
|
||||
/// `take(cap)` stops *at* the cap, which cannot distinguish "exactly cap"
|
||||
/// (within the contract — "exceeds means exceeds") from "at least cap",
|
||||
/// so `cap + 1` makes the boundary unambiguous.
|
||||
fn inflate_capped(reader: impl Read, what: &str) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
let read = reader
|
||||
.take(MAX_INFLATED_BYTES + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{what}: {e}"))?;
|
||||
if read as u64 > MAX_INFLATED_BYTES {
|
||||
return Err(format!(
|
||||
"{what}: inflated data exceeds the {MAX_INFLATED_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Inflate a zlib stream, falling back to the raw deflate data behind its
|
||||
/// two-byte header when the stream's own trailer is wrong.
|
||||
fn inflate_lenient(payload: &[u8], warnings: &mut Vec<String>) -> Result<Vec<u8>, String> {
|
||||
use flate2::read::{DeflateDecoder, ZlibDecoder};
|
||||
let mut decoded = Vec::new();
|
||||
match ZlibDecoder::new(payload).read_to_end(&mut decoded) {
|
||||
Ok(_) => Ok(decoded),
|
||||
match inflate_capped(ZlibDecoder::new(payload), "CTB zlib decompress") {
|
||||
Ok(decoded) => Ok(decoded),
|
||||
Err(zlib_error) if payload.len() > 2 => {
|
||||
let mut raw = Vec::new();
|
||||
DeflateDecoder::new(&payload[2..])
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|_| format!("CTB zlib decompress: {zlib_error}"))?;
|
||||
let raw = inflate_capped(DeflateDecoder::new(&payload[2..]), "CTB zlib decompress")
|
||||
.map_err(|_| zlib_error)?;
|
||||
warnings.push("the zlib stream is damaged; its deflate data was read directly".into());
|
||||
Ok(raw)
|
||||
}
|
||||
Err(zlib_error) => Err(format!("CTB zlib decompress: {zlib_error}")),
|
||||
Err(zlib_error) => Err(zlib_error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,3 +1009,82 @@ mod lenient_loading_tests {
|
||||
assert!(PlotStyleTable::load_named("../escape.ctb").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod inflate_limit_tests {
|
||||
use super::{decompress_ctb, MAX_INFLATED_BYTES};
|
||||
use std::io::Write;
|
||||
|
||||
/// A payload that lands exactly on the cap is within the contract
|
||||
/// ("exceeds means exceeds"): it must load, not be refused. `take(cap)`
|
||||
/// alone can't tell "exactly cap" from "at least cap" — the reader has
|
||||
/// to be allowed one byte past the limit.
|
||||
#[test]
|
||||
fn an_inflate_of_exactly_the_cap_is_accepted() {
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder
|
||||
.write_all(&vec![b' '; MAX_INFLATED_BYTES as usize])
|
||||
.expect("compress");
|
||||
let payload = encoder.finish().expect("finish");
|
||||
let mut data = vec![b'\n'];
|
||||
data.extend(payload);
|
||||
let out = decompress_ctb(&data).expect("exactly the cap must be accepted");
|
||||
assert_eq!(out.0.len() as u64, MAX_INFLATED_BYTES);
|
||||
}
|
||||
|
||||
/// One byte over the cap is refused with the same limit error the
|
||||
/// oversized bombs produce.
|
||||
#[test]
|
||||
fn an_inflate_one_byte_over_the_cap_is_refused() {
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder
|
||||
.write_all(&vec![b' '; (MAX_INFLATED_BYTES + 1) as usize])
|
||||
.expect("compress");
|
||||
let payload = encoder.finish().expect("finish");
|
||||
let mut data = vec![b'\n'];
|
||||
data.extend(payload);
|
||||
let error = decompress_ctb(&data).expect_err("one byte over must be refused");
|
||||
assert!(error.contains("limit"), "{error}");
|
||||
}
|
||||
|
||||
/// Twice the 64 MiB inflate cap the fix introduces — a payload that is
|
||||
/// tiny compressed (spaces) but must not be inflated whole.
|
||||
fn bomb_plaintext() -> Vec<u8> {
|
||||
vec![b' '; 128 * 1024 * 1024]
|
||||
}
|
||||
|
||||
/// A crafted CTB is a few hundred KB of zlib that expands to hundreds of
|
||||
/// MB; `read_to_end` has no output budget, so the `Vec` grows until the
|
||||
/// allocator fails and the process **aborts** (an abort is not a
|
||||
/// catchable panic). Both the zlib path and the legacy raw-deflate path
|
||||
/// must stop at the cap instead.
|
||||
#[test]
|
||||
fn a_zlib_bomb_is_refused_at_the_inflate_limit() {
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&bomb_plaintext()).expect("compress");
|
||||
let payload = encoder.finish().expect("finish");
|
||||
assert_eq!(payload[0], 0x78, "zlib header for the inflate_lenient path");
|
||||
|
||||
let mut data = vec![b'\n'];
|
||||
data.extend(payload);
|
||||
let error = decompress_ctb(&data).expect_err("zlib bomb must be refused");
|
||||
assert!(error.contains("limit"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_deflate_bomb_is_refused_at_the_inflate_limit() {
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&bomb_plaintext()).expect("compress");
|
||||
let payload = encoder.finish().expect("finish");
|
||||
assert_ne!(payload[0], 0x78, "raw deflate takes the legacy DeflateDecoder path");
|
||||
|
||||
let mut data = vec![b'\n'];
|
||||
data.extend(payload);
|
||||
let error = decompress_ctb(&data).expect_err("deflate bomb must be refused");
|
||||
assert!(error.contains("limit"), "{error}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user