Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions bambu_cli/slicer/estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/test_slicer_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down