Skip to content

fix(security): close audit findings on install paths, URI opening and update trust - #55

Merged
MotherSphere merged 2 commits into
mainfrom
fix/security-audit-hardening
Jul 25, 2026
Merged

fix(security): close audit findings on install paths, URI opening and update trust#55
MotherSphere merged 2 commits into
mainfrom
fix/security-audit-hardening

Conversation

@MotherSphere

Copy link
Copy Markdown
Member

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_archive joined the manifest's binary field straight into the install dir, while the zip and tar branches guarded it. A hostile manifest (file without an archive extension + binary: "../../../../.config/autostart/x.desktop") could 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 entire ed25519 self-update chain without attacking any crypto. The project's own threat model covers this: the signed field exists to close the "compromised repo omits signatures" hole.

What changed

  • Traversal: guard hoisted above the branch dispatch so all three paths are covered; repo_name validated as a path component in the seven places it is joined (install dir, per-repo caches, desktop entry, remove_dir_all).
  • URI opening: single http(s) allowlist for everything reaching open::that, which 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. Two aggravating facts confirmed in the sources: iced 0.14 made markdown::Uri a plain String (0.13 parsed it as a url::Url), and fetch_readme never 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.
  • Update trust: launcher updates are bound to a version and an artefact by a signed .meta sidecar (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.
  • Signature pinning: 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 created a different directory and silently dropped the pin.
  • Desktop entries: values escaped per spec and control characters refused - glib keeps the first occurrence of a key, so a newline injected a shadowing Exec=.
  • Windows launch: installed apps run directly instead of through 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 keeps start for .lnk targets but is quoted explicitly via raw_arg.
  • Symlinks: staging writes never follow a symlink planted at a predictable name (<binary>.new, <asset>.part), which File::create would have followed before the caller chmods the target.

Deliberate limits, documented in the code

  • App signatures are still not bound to a version or asset name, so push access to a catalogue repo could replay a different org-signed artefact. Closing it needs the .meta sidecar rolled out to the six ecosystem release workflows first; requiring it now would break every install, since no published release has ever emitted one.
  • Install-time re-verification enforces the asset name, the digest and "strictly newer than the running build" rather than the exact requested tag - the tag is not in scope there. Threading it through would touch the UI state plumbing, which does not belong in a security patch.

Also pre-existing and not addressed here: the sign job 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

  • 119 tests (12 new), clippy --all-targets -D warnings clean, fmt clean.
  • Cross-compiled for Windows to validate the #[cfg(windows)] paths, which cannot run here.
  • scripts/sign-release.sh exercised end to end with a throwaway ed25519 key: digest matches sha256sum, both signatures are 64 raw bytes, and it now fails closed when the version is not supplied.
  • The traversal test was checked to actually fail when the fix is reverted - the first version of it passed either way, because its escape targets did not exist on disk and the rename failed on ENOENT instead of on the guard.

Release coupling

Self-update becomes fail-closed on the sidecars, so the sign job now emits and verifies .sig + .meta + .meta.sig for 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.

… 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.
@MotherSphere
MotherSphere merged commit fdc9e16 into main Jul 25, 2026
3 checks passed
@MotherSphere
MotherSphere deleted the fix/security-audit-hardening branch July 25, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant