fix(security): close audit findings on install paths, URI opening and update trust - #55
Merged
Merged
Conversation
… update trust A full security audit of the launcher (six parallel review axes, each finding double-checked by adversarial verifiers) surfaced seven real issues. All of them are variants of one weakness: remote strings from a manifest or a README were trusted at the point of use rather than at the boundary. The serious one: the raw-binary branch of `extract_binary_from_archive` joined the manifest's `binary` field straight into the install dir, while the zip and tar branches guarded it. A hostile manifest could therefore write an executable anywhere under $HOME - the installer then chmods it 0755 and writes a desktop entry pointing at it - including over Colony's own binary, bypassing the whole ed25519 self-update chain without attacking any crypto. - Hoist the traversal guard above the branch dispatch so all three paths are covered, and validate `repo_name` as a path component in the seven places it is joined (install dir, per-repo caches, desktop entry, remove_dir_all). - Gate everything reaching the desktop URI opener behind an http(s) allowlist. `open::that` is not a browser call: it dispatches file://, UNC paths and any registered scheme handler, so a link in a fetched README was a one-click execution primitive. iced 0.14 made `markdown::Uri` a plain String, dropping the URL parsing 0.13 did, and `fetch_readme` never truncated as documented, so the whole document is rendered with live links. Return the reparsed URL and open that: the WHATWG parser strips control characters anywhere in the input, so checking one string and opening another judged a different value than it executed. - Bind launcher updates to a version and an artefact with a signed `.meta` sidecar. A signature over raw bytes proves only that they came from the release key, so an older, genuinely signed build could be replayed as an update. Enforced fail-closed at download AND at install, like the signature. - Pin the app signature requirement client-side: `manifest.signed` lives in the repo it protects, so a compromised repo could flip it to false and drop the .sig. Once an install is signature-verified, later updates must stay signed. Matched case-insensitively, since a case-only rename made a new directory. - Escape desktop-entry values per spec, and reject control characters: glib keeps the first occurrence of a key, so a newline injected a shadowing Exec=. - Launch installed apps directly instead of through `cmd /C start`, which re-parsed `&` in a manifest-derived path as a command separator; quote the scanned-app path explicitly with raw_arg, where `start` is still needed for .lnk targets. - Never follow a symlink planted at a predictable staging name. Two limits are deliberate and documented in the code: app signatures are still not bound to a version or asset name (that needs the sidecar rolled out to the ecosystem release workflows first), and install-time re-verification enforces "strictly newer than the running build" rather than the exact requested tag. CI declares the asset list once instead of per step, checks each sidecar binds the right tag, and re-verifies against the published release.
The new signature-pin test uses with_temp_dirs, which is cfg(target_os = "linux") because only Linux honors XDG for the data dir. Gate it, and restore the gate that inserting it above toggle_favorite_persists_to_disk had taken from that test - both broke the Windows and macOS builds.
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.
Full security audit of the launcher, then the fixes. Six parallel review axes (self-update trust chain, archive extraction and disk writes, process execution, network and manifests, secrets and OAuth, remote content rendering); every finding was double-checked by two adversarial verifiers with distinct lenses, which killed 8 of 15 candidates. The 7 survivors are fixed here, along with the defects a second adversarial pass found in the fixes themselves.
Seven issues, one root cause: remote strings from a manifest or a README were trusted at the point of use rather than at the boundary.
The serious one
The raw-binary branch of
extract_binary_from_archivejoined the manifest'sbinaryfield straight into the install dir, while the zip and tar branches guarded it. A hostile manifest (filewithout an archive extension +binary: "../../../../.config/autostart/x.desktop") could write an executable anywhere under$HOME- the installer then chmods it0755and writes a desktop entry pointing at it - including over Colony's own binary, bypassing the entire ed25519 self-update chain without attacking any crypto. The project's own threat model covers this: thesignedfield exists to close the "compromised repo omits signatures" hole.What changed
repo_namevalidated as a path component in the seven places it is joined (install dir, per-repo caches, desktop entry,remove_dir_all).open::that, which is not a browser call - it dispatchesfile://, UNC paths and any registered scheme handler, so a link in a fetched README was a one-click execution primitive. Two aggravating facts confirmed in the sources: iced 0.14 mademarkdown::Uria plainString(0.13 parsed it as aurl::Url), andfetch_readmenever truncated as its doc-comment claims, so the whole document renders with live links. The helper returns the reparsed URL and we open that, because the WHATWG parser strips control characters anywhere in the input - validating one string and opening another judged a different value than it executed..metasidecar (version/asset/sha256). A signature over raw bytes proves only that they came from the release key, so an older genuinely signed build could be replayed as an update. Enforced fail-closed at download and at install, like the signature.manifest.signedlives in the repo it protects, so a compromised repo could flip it to false and drop the.sig. Once an install is signature-verified, later updates must stay signed. Matched case-insensitively, since a case-only rename created a different directory and silently dropped the pin.Exec=.cmd /C start, which re-parsed&in a manifest-derived path as a command separator (Rust only quotes arguments containing whitespace); the scanned-app path keepsstartfor.lnktargets but is quoted explicitly viaraw_arg.<binary>.new,<asset>.part), whichFile::createwould have followed before the caller chmods the target.Deliberate limits, documented in the code
.metasidecar rolled out to the six ecosystem release workflows first; requiring it now would break every install, since no published release has ever emitted one.Also pre-existing and not addressed here: the
signjob runs after release-please publishes, so a signing failure leaves a public release that self-update refuses. Publishing as a draft and flipping after signing is the fix, and it deserves its own tested change.Verification
clippy --all-targets -D warningsclean,fmtclean.#[cfg(windows)]paths, which cannot run here.scripts/sign-release.shexercised end to end with a throwaway ed25519 key: digest matchessha256sum, both signatures are 64 raw bytes, and it now fails closed when the version is not supplied.Release coupling
Self-update becomes fail-closed on the sidecars, so the
signjob now emits and verifies.sig+.meta+.meta.sigfor all four platforms, declares the asset list once instead of per step (a per-step list is how v0.7.0 shipped unsigned), checks each sidecar binds the right tag, and re-verifies against the published release. The transition is safe in both directions: the published 0.9.0 client only requires.sig, so it updates to this release normally, and every release from this one on carries the sidecars.