test(project): cover runtime_assets checksum and extraction helpers - #75
Open
Adebowale-Morakinyo wants to merge 2 commits into
Open
test(project): cover runtime_assets checksum and extraction helpers#75Adebowale-Morakinyo wants to merge 2 commits into
Adebowale-Morakinyo wants to merge 2 commits into
Conversation
The containment check was `str(target).startswith(str(dest))`, a string prefix rather than a path boundary. With a destination basename of `out`, a member named `../outsider.txt` resolves to a sibling whose path begins with the destination's, and passed. So did `../out-evil/x.txt`. `../escape.txt` was correctly blocked, which is why the hole survived. The loop also read `member.name` and never `member.linkname`. A symlink `escape_dir -> ../outside_target` followed by a member `escape_dir/payload.txt` has two innocuous names, passed both checks, and wrote through the symlink to a directory outside the destination. Hardlinks and absolute-target symlinks were equally unguarded. Containment now uses `Path.is_relative_to`, available across the supported range, and link members have their target resolved and boundary-checked -- symlinks against the link's own directory, hardlinks against the archive root. `extractall(filter="data")` blocks all of this and is the reason none of it was exploitable on a current interpreter. The comment claiming it as 3.12+ understated its availability: it is present from 3.10.12 and 3.11.4, the PEP 706 backports, and absent in 3.10.11 and 3.11.3. `requires-python = ">=3.10"` admits those, where the manual check is the only defence.
Adebowale-Morakinyo
force-pushed
the
test/project-runtime-assets
branch
from
September 4, 2026 08:52
807d326 to
391fbcd
Compare
Contributor
Author
|
Rebased after 3.13/3.14 landed; the tests now derive versions from |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
tests/project/test_runtime_assets.py, and fixes two path-escape holes in_safe_extractthat writing those tests uncovered.Closes #57.
Rebased onto v0.39.0. The supported range moved to 3.13/3.14 while this was in review; test version literals are now derived from
PINNED_ASSETSrather than hardcoded, so the next bump needs no edits here. ThePINNED_ASSETS/SUPPORTED_PYTHON_VERSIONSinvariant test passed across that bump — both lists moved together in 8cebee5, which is the drift it exists to catch.Scope change
The issue says no changes to
runtime_assets.pyshould be needed. That was written without knowing the containment check was broken. The fix is one self-contained chunk — 15 insertions in one function, in its own commit — so it can be split out if you'd rather have tests-only and a separate fix PR.Hole 1: the containment check was a string prefix
str(target).startswith(str(dest))accepts any sibling whose path merely begins with the destination's. With a destination basename ofout,../outsider.txtresolves to<parent>/outsider.txtand../out-evil/x.txtto<parent>/out-evil/x.txt— both passed.../escape.txt, the one member the issue's suggested test names, was correctly blocked. So a test written to the issue's letter would have reported green over a live hole.Fixed with
Path.is_relative_to.Hole 2: link members were never checked
The loop read
member.nameand nevermember.linkname. A symlinkescape_dir -> ../outside_targetfollowed by a memberescape_dir/payload.txthas two innocuous names, passed the check, and on the fallback path wrote the payload outside the destination through the symlink. Confirmed on disk against a real pre-3.10.12 interpreter. On a supported interpreter,filter="data"refuses it first, so this was never exploitable here — but the manual check approved it, which is the part being fixed.Hardlinks and absolute-target symlinks were equally unguarded.
Fixed by resolving
linkname— against the link's own directory for symlinks, against the archive root for hardlinks — and applying the same boundary test.Severity, honestly
requires-pythonis>=3.13, sofilter="data"exists on every supported interpreter and neither escape was exploitable in practice. The second layer caught what the first let through.The fix still matters. The manual loop runs first, before
extractall, and it is the check whose docstring says it refuses entries that escape. It didn't. A boundary check that silently defers to a downstream layer is one refactor away from being the only check again, and the module's whole job is validating a downloaded artifact.filter="data"is now available on every supported interpreter, so theexcept TypeErrorfallback in_safe_extractis unreachable on>=3.13. I've left it in place and flagged it in a comment rather than removing it — @owenthcarey's call. Note that removing it would also make the..._without_the_filtertests untestable as written, since the_NoFilterTarshim exists to force that branch.The fallback itself is worth treating separately if it is ever retained for older Python versions. The manual path is not a
filter="data"equivalent: it covers path and link escapes but does not sanitize modes — setuid, setgid, and the permissions a.directory member can impose are still platform-dependent on the fallback.On the two extraction tests, honestly
After the fix, the manual check fires before
extractallfor every member in the test set, sotest_..._refuses_escaping_membersandtest_..._refuses_escaping_members_without_the_filterexercise the same code. The second is not proving "the fallback works."What it does prove is narrower: matching on
RuntimeErrorpins which layer did the work. Restorestartswithand the non-fixture test fails — not because the escape gets through, but becausefilter="data"catches it and raisestarfile.OutsideDestinationErrorinstead. The fixture variant then shows the manual check suffices with the filter gone. That's stated in the helper's docstring rather than left implicit.Not covered:
filter="data"in isolation. Testing that would mean removing the manual check, which is the mutation, and the filter is CPython's code rather than ours.Every escape test also asserts nothing landed outside the destination, independently of exception type.
Testing
21 tests, no network.
./scripts/check.shpasses.Verified against the real assets: all three pinned Python-Apple-support archives were downloaded, their SHA-256 values matched the pins, every member passed the new checks, and each archive's three symlinks stayed inside its extraction root. Full extraction and runtime discovery succeeded, so the fix doesn't over-block anything real.
The version boundaries were confirmed on actual interpreters, not from the changelog: 3.10.11 and 3.11.3 have neither
filternordata_filter; 3.10.12, 3.11.4, and 3.12.0 have both. The suite passes on all four.The
PINNED_ASSETS/SUPPORTED_PYTHON_VERSIONSinvariant test also passed across the v0.39.0 rebase. Both lists moved together in 8cebee5, which is the drift the invariant exists to catch.Beyond the issue's list: the
prepare_ios_runtimecache paths, which were the only untested part of that function — the early return, asserting the log callback stayed silent since both other branches emit, and the stale-cache fall-through. Plus aPINNED_ASSETSshape check, a symlink-that-stays-inside test guarding against over-blocking, a_sha256payload spanning three of the function's 1 MiB chunks, and a test that the network block itself is live.The socket block is module-scoped rather than in
tests/conftest.py, becausetests/test_net.py:189opens a real socket to find a free port.Both fixes are mutation-checked. Reverting containment to
startswithfails the four sibling-prefix cases and leaves the parent-dir case passing, which is correct since../escape.txtis caught either way. Removing thelinknamecheck fails the two link tests.One thing left alone
cache_dir.mkdir()runs before the pinned-version check, soprepare_ios_runtime(cache, "2.7")creates the directory and then raises. The test asserts this so a future reorder is a deliberate choice rather than a silent one. Changing it isn't in scope here.filter="data"remains unchanged and is the primary defence on every supported interpreter.