fix(lemonade): validate downloaded archives - #143
Conversation
bb89ce9 to
cf10418
Compare
d7ad594 to
5c5b92a
Compare
5c5b92a to
a0b1f52
Compare
volen-silo
left a comment
There was a problem hiding this comment.
Solid, well-tested hardening — the core acquisition path is genuinely good (see the end of this review). Three things I would like addressed before merge, all in the same theme: the trust boundary ends one step earlier than the PR describes.
Verified locally: cargo test -p rocm-engine-lemonade --lib (48 passed), cargo clippy -p rocm-engine-lemonade --all-targets -- -D warnings clean. Both pinned digests confirmed byte-correct against the live upstream release artifacts (downloaded and hashed lemonade-embeddable-10.10.0-windows-x64.zip and -ubuntu-x64.tar.gz from the v10.10.0 release).
1. Verification and use are not atomic — the locks are released before extraction
engines/lemonade/src/lib.rs:986-1001
ensure_cached_archive acquires _process_guard and _archive_guard (lines 1410-1413), verifies the digest, and returns — dropping both guards. prepare_embeddable then calls extract_archive(&archive, &extract_root)? at line 1001 with no lock held, which re-opens the file from disk:
ensure_cached_archive(archive_url, &archive, embeddable_archive_sha256(), download_file)?;
...
extract_archive(&archive, &extract_root)?;So the bytes that are verified and the bytes that are extracted are two separate reads with an unlocked window between them. A co-resident writer to <root>/downloads/ can swap the archive in that window and the install proceeds on unverified content — which is precisely the property the file lock was introduced to guarantee.
The check-then-act coverage inside ensure_cached_archive_after_invalid is correct; it is the boundary at the return that leaks. Suggestion: have ensure_cached_archive return the ArchiveCacheLock and hold it across extract_archive, or re-verify immediately before extraction.
2. extract_archive — the one step that writes untrusted bytes — is outside the fix
engines/lemonade/src/lib.rs:1456-1467
This function is unchanged by the PR (it appears in the diff only as context). It shells out to tar and lets it write directly into destination:
let mut command = ProcessCommand::new("tar");
command.arg(if runtime_is_windows() { "-xf" } else { "-xzf" });
command.arg(archive).arg("-C").arg(destination);No entry-name validation, no --no-same-owner, and no post-extraction sweep. The new collect_embeddable_roots / copy_tree_entries checks only walk descendants of extract_root, so anything tar writes outside that root is never seen by them. Whatever traversal protection exists is whatever the locally installed tar happens to implement, which differs between GNU tar, the bsdtar that ships as Windows tar.exe, and busybox tar.
To be fair: this is only reachable if the digest pin is defeated, since ensure_cached_archive gates the call. So it is defense-in-depth, not a live vulnerability. But the root-cause section says the gap was "allowing archive-tree traversal outside the extracted tree", and the step that actually writes bytes to arbitrary paths is the one step left untouched. A containment sweep of extract_root after tar returns would close it cheaply.
Adjacent, pre-existing, worth a thought while you are here: ProcessCommand::new("tar") resolves the extractor from PATH. In a change about not trusting downloaded bytes, the binary that unpacks them is itself unpinned and unvalidated.
3. Symlink rejection is untested on Windows
engines/lemonade/src/lib.rs:3730 and :3753
embeddable_tree_rejects_external_file_symlink and embeddable_tree_rejects_external_directory_symlink are both #[cfg(unix)] and use std::os::unix::fs::symlink. Windows is this engine's primary platform — the Windows zip is the first-class artifact — so the entire is_symlink() rejection path added here (lines 1521, 1558, 1612) ships with no verification on the platform that matters most.
I checked the underlying semantics and the code is correct: Rust's Windows FileType::is_symlink() keys off the reparse point name-surrogate bit, so it returns true for junctions (IO_REPARSE_TAG_MOUNT_POINT) as well as symlinks. And a reparse point without that bit falls through to the !is_dir() && !is_file() arm and is rejected as a non-regular entry. Both good. But none of that is pinned down by a test.
Creating a symlink on Windows needs privilege, which I assume is why these are Unix-gated — but mklink /J creates a junction without elevation, so a junction-based test is feasible in CI where a symlink test is not, and the junction is exactly the case most likely to regress.
Non-blocking
- No test for the PR's headline case: a download that succeeds but returns bytes failing the digest.
poisoned_cached_archive_is_removed_and_redownloadedcovers a poisoned cache;failed_download_removes_private_partial_filecovers a download error. Nothing covers bad-content download. The code is correctly guarded (verify_sha256(&temporary_path, ...)?precedespublish_verified_archive), so this is a missing regression test rather than a bug — but a refactor that reordered those two lines would not be caught. - "Owner-private temporary file" holds on Unix only.
tempfilesets mode 0600 on Unix but sets no ACL orSECURITY_ATTRIBUTESon Windows, so the file inherits the parent directory's DACL — no better than plainFile::create. The error context at line 1443 says "failed to create private download beside {}". Worth wording for what is actually guaranteed per platform. concurrent_valid_archive_publication_succeeds_for_both_installers(3451) andconcurrent_invalid_archive_publication_fails_closed(3470) never exercise the locking path — they are a single-threadedfor _ in 0..2callingpublish_verified_archivedirectly, never reachinglock_archive_cacheor the mutex. They do genuinely test publish-conflict detection, so they are misnamed rather than useless; the real cross-process coverage isinterprocess_lock_serializes_poisoned_validation_and_recovery.EMBEDDABLE_ARCHIVE_CACHE_LOCKis a single global static, so it serializes installs of every archive andenv_rootin-process even though the per-archive file lock already provides correctness. Combined withfile.lock()having no timeout or try-lock, one install stuck inside its 15-minute download window blocks every other in-process install regardless of which archive it wants.copy_tree_entrieshas no depth bound while its siblingcollect_embeddable_rootscaps atdepth > 4(line 1517). Moot while the archive is digest-pinned; noting the asymmetry.
Worth calling out as good
publish_verified_archiveusespersist_noclobberand, on anAlreadyExistsconflict, re-verifies the file the winner published rather than assuming it is fine — fails closed on the race.- The temp file is created with
tempfile_in(parent), i.e. the destination's own directory, so the persist is a same-filesystem atomic rename rather than a copy. - The cache is re-verified on every use, not only on write.
- Mutex poisoning is recovered with
PoisonError::into_innerinstead of.unwrap(), and there is a test for it. interprocess_lock_serializes_poisoned_validation_and_recoveryspawns real child processes viacurrent_exe(), waits on stage markers, and asserts the second process did not acquire the lock while the first held it. That is a real concurrency test, and the#[cfg(test)]gating on the stage-marker and heartbeat instrumentation is clean — none of it compiles into the release binary.- Containment uses component-wise
Path::starts_withagainst roots thatcopy_treecanonicalizes on both the source and destination side — not the string-prefix bug this pattern usually has.
|
Thanks — the three blocking points were all correct, and one of them turned out to be sharper than the framing in the review. Addressed in 56e9ddf (plus 62334d3 recording the new dependencies). The reviewed commit a0b1f52 is untouched; everything below is a follow-up commit on top of it. 1. Verification and use are not atomicCorrect, and fixed at the boundary you identified. I confirmed the test is load-bearing rather than tautological: with the guard released before the return (the previous behaviour), 2.
|
|
CI is green on 62334d3, which closes the one verification gap I flagged above: |
|
Review note:
This PR reuses the same constant as a hard cap on the entire copied tree in The new tests only exercise a tree 1-2 levels deep ( Suggestion: decouple into two separate constants (a search-depth limit vs. a much larger — or unlimited — copy-depth limit), or verify the real official archive's actual nesting depth and add a test with realistic depth before merging. |
|
Good catch — you were right that this was a materially different guarantee, and it's fixed in c64d184. Verified against the real archive first. The official The structural objection stands regardless of the current archive, which is why I fixed it rather than just recording the measurement. The cap bought nothing: containment in Now two constants:
New test Leaving this open for you to confirm — particularly the unmeasured Windows layout, if you have that artifact handy. |
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Review follow-up to the archive-validation change. Extraction shelled out to `tar`, resolved from `PATH`. A test that prepends a directory containing a fake `tar` to `PATH` shows the impostor is executed, so the binary unpacking untrusted bytes was itself unpinned; its traversal behaviour also varied between GNU tar, the bsdtar shipped as Windows `tar.exe`, and busybox tar. Extraction now runs in-process via `tar`/`flate2` and `zip`, validating every entry name against the extraction root and rejecting links, devices and other non-regular entries before anything is written. The cache lock was also released when verification returned, leaving an unlocked window in which the verified bytes and the extracted bytes were two separate reads. Verification now returns a guard that the caller holds across extraction and copy-out. Because that widens the hold, in-process exclusion is keyed per archive path instead of a single global mutex, so unrelated installs no longer queue behind a download window. Also: regression test for a download that succeeds with wrong bytes, a Windows junction test for the reparse-point rejection path, a depth bound on copy_tree_entries matching its sibling, per-platform wording on the temporary download's permissions, and renamed publish-conflict tests that never exercised locking. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The embeddable-root search depth was reused as a hard cap on the copied tree, turning a give-up heuristic into a fatal limit that would reject a legitimate archive nested more than four levels deep. Containment is already enforced per entry, so copying now uses a separate recursion backstop set well above any plausible layout. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Extraction rejected any tar entry outside Directory/Regular/Continuous. The `tar` crate consumes long-name, long-link and pax *local* headers itself, but hands a pax *global* header to the caller, so an archive written by `git archive` or `tar --format=pax` was refused with "unsupported entry (XGlobalHeader)" even though the header describes no member at all. Global headers are now skipped. Sparse entries stay rejected, but on their own footing and with their own message: the crate would expand them correctly, yet a few-byte header can declare a multi-gigabyte logical size, which would turn a digest-pinned archive into an unbounded write. Both archive-cache locks could also block indefinitely without printing anything, so an install waiting behind a peer's download window looked hung. The in-process wait announces itself, and the file lock is probed with `try_lock` first so only a contended acquire is reported. Also: the link rejections now point at a single documented policy on `extract_archive` explaining that failing closed is deliberate and how to widen it if upstream ever ships a symlink; the zip path's `!is_file()` arm is dropped as unreachable, since `zip` defines `is_file` as the complement of the directory and symlink cases handled above it. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
c64d184 to
c49e090
Compare
|
Force-pushed over the commit @volen-silo reviewed ( Already addressed before this push (in
New in this push:
Verification: 61 lemonade tests pass, workspace clippy clean with One gap called out in the description: CI runners reuse a pre-warmed runtime tree, so their logs show the digest check running but extraction being skipped. A fresh clean-root install against the real v10.10.0 artifacts is still needed before merge. The CodeQL threads are left open for a maintainer to dismiss. |
r0x0r
left a comment
There was a problem hiding this comment.
Post-merge review notes (informational — PR is already merged and approved).
Strengths
- Sound threat model. The three trust boundaries — unverified bytes, the unpinned
PATH-resolvedtar, and link-following traversal — are each closed.write_entry_fileusingcreate_new(true)correctly defends against a symlink planted between entries (extraction-time TOCTOU). - Fail-closed link policy is consistent and well-documented on
extract_archive, with the widen-don't-drop guidance repeated at each enforcement point (extraction, root discovery, copy-out). - Excellent test coverage. Hostile tar names stamped into raw headers, absolute/
../nested traversal, symlink entries (tar + zip), pax-global-header acceptance, thePATH-supplied-tarsubprocess test, cross-process lock serialization, and publish-conflict-fails-closed. Unusually thorough. - Correct concurrency reasoning: the guard is held across extraction and copy-out, so the verified bytes are the used bytes; poisoned-mutex recovery via
into_inner; per-archive keying avoids serializing unrelated installs.
Observations / residual risks
None are correctness bugs given the pinned-digest invariant; item 1 is the only one worth an active follow-up.
-
Windows end-to-end path is still unverified (as the PR description notes).
download_file_to_pathcallsFile::create(destination)whiletempfile::NamedTempFileholds an open handle to the same path. Rust std opens withFILE_SHARE_READ|WRITE|DELETE, so the second handle should succeed andpersist_noclobber(a rename) works — but this was never exercised against the real published zip. A clean-root install on Windows against the v10.10.0 artifact would confirm rather than infer. -
No decompressed-size cap in
extract_zip(zip-bomb). Acceptable because the archive is digest-pinned, but the safety here rests implicitly on the digest check, unlike the tar path where the sparse-entry rejection is called out explicitly. -
Residual TOCTOU in
copy_tree_entriesbetweencanonicalize(&source_path)andfs::copy(&canonical_source, …). Low risk since the extraction root is a process-private temp dir under an exclusive lock, but containment leans on that locking rather than being intrinsic. -
copy_treeswitchedcreate_dir_all→create_dirfor the destination root, tightening the precondition to require a pre-existing parent. Fine for the currentprepare_embeddablecaller (parentrootexists); just worth noting for future callers.
|
Thanks for the post-merge look.
No action planned against this PR (already merged); logging these as known, accepted trade-offs for now. |
Summary
tarsubprocess with in-process extraction (tar/flate2for.tar.gz,zipfor.zip), validating every entry name against the extraction root.Root cause
Downloaded and cached archives were trusted without integrity verification. Extraction then shelled out to
tarresolved fromPATH, so the binary unpacking untrusted bytes was itself unpinned, and its traversal behaviour differed between GNU tar, the bsdtar shipped as Windowstar.exe, and busybox tar. Recursive embeddable-root discovery and copying also used path helpers that followed links, allowing archive-tree traversal outside the extracted tree.Technical decisions
In-process extraction. Entry names are validated component-by-component against the extraction root, and only directories and regular files are written. A pax global header is skipped rather than rejected: it carries archive-wide metadata and describes no member, and
git archiveandtar --format=paxboth emit one, so treating it as a member would refuse a valid archive. Sparse entries stay rejected — the crate would expand them correctly, but a few-byte header can declare a multi-gigabyte logical size, which would turn a digest-pinned archive into an unbounded write.Link policy. Links are rejected rather than resolved, which is deliberately conservative and fails closed on an authentic archive. If a future Lemonade release ships one, the fix is to widen the policy (resolve the target, accept it only if it stays inside the extraction root), not to drop the check. This is documented on
extract_archive.Dependencies. Adds
flate2,sha2,tar,tempfileandzipto this crate. All were already in the workspace lock, so the workspace dependency set — and thereforeMANIFEST.mdandTHIRD_PARTY_NOTICES.txt— is unchanged.zipdeliberately matchesxtask's major and its pure-Rustdeflatebackend so the workspace carries onezipand one deflate implementation.Scope. This hardens the archive acquisition and extraction boundary without redesigning runtime installation as a transaction. Runtime replacement already occurs after archive preparation, while the identified trust gap was accepting unverified archive bytes and following unsafe archive-tree entries; a broader transaction redesign would add unrelated scope and operational risk.
Risk
Medium. The install path is intentionally stricter and will discard a corrupt cached archive or reject an archive tree containing links or special entries. Valid official archives retain the existing install behavior.
Test plan
tests/e2e-cucumber/expectations.tomland found no matching stale xfail row to remove or narrow.cargo xtask manifest --checkpassed.proc_lifecyclefailures inrocm-core, untouched by this change; everything else passes.Using verified cached ...) but extraction being skipped — the in-process extractor has not yet been exercised end-to-end against the published archives.