From f99077c17989b3b9f4273b9b6f27d45d36e2d4cd Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Wed, 28 Oct 2026 22:28:08 -0700 Subject: [PATCH] install: check archive paths with is_relative_to, not a string prefix Co-Authored-By: Claude Opus 5.5 --- src/simantic/install.py | 3 ++- tests/test_install.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/simantic/install.py b/src/simantic/install.py index 6720753..359fa2a 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -484,7 +484,8 @@ def _unpack_zip(payload: bytes, target: Path) -> None: for name in archive.namelist(): # Refuse anything that would land outside the target. dest = (incoming / name).resolve() - if not str(dest).startswith(str(incoming.resolve())): + # A path check, not a string prefix: `.x.incoming-evil` shares the prefix. + if not dest.is_relative_to(incoming.resolve()): raise InstallError(f"archive has an unsafe path: {name}") archive.extractall(incoming) if target.exists(): diff --git a/tests/test_install.py b/tests/test_install.py index 8f7e031..2a2d80e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -628,3 +628,11 @@ def test_pyrite_manifest_resolves_sim_and_rust_engine(home, monkeypatch): assert install.resolve("sim").path == "pyrite/1.2.3/sim-osx-arm64" monkeypatch.setattr(install, "download", lambda a, timeout=300: engine_zip({"simantic_rust.abi3.so": b"x"})) assert install.install_rust_engine() == install.rust_engine_root() / "1.2.3" + + +def test_unpack_zip_refuses_a_sibling_sharing_the_prefix(tmp_path): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("../.engine.incoming-evil/f", b"x") + with pytest.raises(install.InstallError, match="unsafe path"): + install._unpack_zip(buf.getvalue(), tmp_path / "engine")