Refuse a deployment download that climbs out of its own directory - #410
Open
kevin9327 wants to merge 1 commit into
Open
Refuse a deployment download that climbs out of its own directory#410kevin9327 wants to merge 1 commit into
kevin9327 wants to merge 1 commit into
Conversation
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 6, 2026 23:19
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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 this changes
The desktop shell fetches the release tarball and lays it out under the directory it manages. The
check that was meant to keep an entry inside that directory does not:
Path::starts_withcompares components and does not resolve.., so a path that climbs out stillbegins with the root:
The file filter above it does not stop this either: it only inspects the FIRST component, and
app/../../evilbegins withapp, which is one of the directories a deployment wants.entry.unpack(&destination)is then given an absolute path and writes there. That is the tar-rscall that does no containment checking of its own —
unpack_inis the one that does — so nothingbetween the archive and the disk asks the question the comment says is being asked.
Measured
A tarball whose only entry is
OpenBot-0.0.8/app/../../escaped.txt, unpacked into a root onedirectory below the scratch directory. Against
main:The file is written and the call returns
Ok.The archive for that test is assembled from raw ustar bytes rather than through
tar::Builder,because the builder refuses such a path outright — "paths in archives must not have
..". Thatrefusal is the right behaviour for a writer and it is exactly why the reader cannot lean on it: an
archive shaped like this is not produced by a careful writer. A second test asserts the archive
really does carry the traversal, so the first cannot pass for the wrong reason.
How far this goes
Honestly: the tarball comes from
github.com/CopilotKit/OpenBot/archive/refs/tags/{version}.tar.gzover HTTPS at a pinned tag, so reaching it means controlling that release or the transport. This is
not something an ordinary user can trigger today.
I still think it is worth fixing rather than reasoning away. The code already decided the archive is
untrusted — "A tarball is somebody else's file" — and built a guard for it; the guard just does not
hold. A check that reads as protection and is not is worse than no check, because it is the reason
nobody looks again. This is also the one place in the app that writes files it did not author, and
it runs on a person's own machine with their own permissions.
Fix
The traversal is refused at the path rather than at where it landed. Every component of a path
inside the tree is an ordinary name, so anything else —
.., an absolute path, a Windows driveprefix — is refused, and the refusal names the entry.
The strip-the-wrapper-directory step, the wanted-list and the unpack are unchanged; they now live in
destination_in, which answers "where may this entry go, if anywhere" and is a pure function of theroot and the entry path.
fetchis split so the extraction loop isunpack(root, body). That is what makes the partdeciding where somebody else's archive may write testable without a network — the reason the fault
survived is that nothing could reach it.
Where it runs
writes to a directory it owns on one machine.
computer, one per install. The deployment it lays down is what the replicas later run.
retried rather than trusted.
Boundary and audit
deployment exists, so there is no audit store; the refusal reaches the person through the
setup screen's progress, the same way every other fetch failure does.
Changelog
CHANGELOG.mdunderUnreleased.Tests
Three in
desktop/src-tauri/src/deployment.rs:says what it refused
app/index.tsanddocker-compose.ymlinplace,
docs/skipped rather than refusedHow I tested
Windows 11.
cargo test --libindesktop/src-tauriis 73 passed, 0 failed, up from 70 by the threenew tests.
cargo fmt --all -- --checkandcargo clippy --libare clean. The crate has norust-toolchain.toml, so I built it with the 1.98.0 toolchain that was installed here.