diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..eb5aca9 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-08-19 - Bounded member decompression for 3MF zip archives +**Vulnerability:** `read_3mf_estimate` extracted `slice_info.config` from `.3mf` zip packages using unbounded `zf.read()`, risking memory exhaustion / Zip Bomb DoS when reading untrusted models. +**Learning:** Even internal helper methods like `read_3mf_estimate` process user-provided or network-downloaded 3MF files. +**Prevention:** Check `info.file_size` against a safety limit (10MB) and use explicit read bounds (`fh.read(limit)`) before parsing XML/JSON from zip archives. diff --git a/bambu_cli/slicer/estimate.py b/bambu_cli/slicer/estimate.py index 68a18b0..8965fee 100644 --- a/bambu_cli/slicer/estimate.py +++ b/bambu_cli/slicer/estimate.py @@ -11,6 +11,7 @@ _MAX_GRAMS = 10000.0 GCODE_READ_BYTES = 65536 # 64 KB +SLICE_INFO_READ_BYTES = 10 * 1024 * 1024 # 10 MB safety limit for XML config (Zip Bomb protection) @dataclass(frozen=True) @@ -22,8 +23,7 @@ class Estimate: def _parse_slice_info(xml_text: str) -> tuple[int | None, float | None]: """Parse prediction (seconds) and weight (grams) from slice_info.config XML. - Uses xml.etree.ElementTree which is safe for local files we produced; - this is not parsing untrusted network XML. + Uses xml.etree.ElementTree to parse bounded metadata XML from .3mf packages. """ try: root = ET.fromstring(xml_text) # nosec B314 — local file produced by OrcaSlicer, not network input @@ -114,10 +114,15 @@ def read_3mf_estimate(path: str) -> Estimate: break if slice_info_name is not None: - xml_text = zf.read(slice_info_name).decode("utf-8", errors="replace") - seconds, grams = _parse_slice_info(xml_text) - if seconds is not None or grams is not None: - return Estimate(seconds, grams) + info = zf.getinfo(slice_info_name) + # Security: Enforce size limit to prevent memory exhaustion / Zip Bomb DoS on untrusted .3mf files + if info.file_size <= SLICE_INFO_READ_BYTES: + with zf.open(slice_info_name) as fh: + xml_bytes = fh.read(SLICE_INFO_READ_BYTES) + xml_text = xml_bytes.decode("utf-8", errors="replace") + seconds, grams = _parse_slice_info(xml_text) + if seconds is not None or grams is not None: + return Estimate(seconds, grams) # slice_info.config was present but yielded nothing usable # (malformed XML, or only implausible values). Fall through to # the gcode header rather than reporting "unknown" -- a truncated diff --git a/tests/test_slicer_estimate.py b/tests/test_slicer_estimate.py index 7d5ce34..8986d9e 100644 --- a/tests/test_slicer_estimate.py +++ b/tests/test_slicer_estimate.py @@ -71,6 +71,19 @@ def test_unparseable_slice_info_falls_back_to_gcode(tmp_path): assert read_3mf_estimate(path).seconds == 2700 +def test_oversized_slice_info_falls_back_to_gcode(tmp_path): + """An oversized slice_info.config (>10MB limit) must be skipped and fall back to gcode header.""" + oversized_data = b"a" * (10 * 1024 * 1024 + 1) + path = _write_zip( + tmp_path / "oversized.3mf", + { + "Metadata/slice_info.config": oversized_data, + "Metadata/plate_1.gcode": b"; model printing time: 20m 0s\n", + }, + ) + assert read_3mf_estimate(path).seconds == 1200 + + def test_slice_info_with_only_implausible_values_falls_back_to_gcode(tmp_path): """A well-formed config carrying junk must not shadow a good gcode header.""" bad_config = (