Skip to content

fix(security): close tap-controlled path and sandbox escapes - #790

Open
rustytrees wants to merge 2 commits into
indaco:mainfrom
rustytrees:fix/tap-path-traversal-and-sandbox-escapes
Open

fix(security): close tap-controlled path and sandbox escapes#790
rustytrees wants to merge 2 commits into
indaco:mainfrom
rustytrees:fix/tap-path-traversal-and-sandbox-escapes

Conversation

@rustytrees

Copy link
Copy Markdown

Fixes the contained half of #789 — the issues whose fix carries little regression risk. The archive extractor changes are deliberately kept separate.

A tap controls cask JSON, post_install source, and the artifact bytes, so every string it supplies that reaches a path or a spawn has to be screened. Four were not.

Cask artifact paths

parseCask already screens token/version through fs/path_component.zig and describes itself as "the one ingestion choke point … A compromised tap is the threat". The artifact strings went straight through:

  • app becomes <app_dir>/<name> and is handed to deleteTree before and independently of the copy, so {"app":["../PRECIOUS"]} recursively deleted a directory outside the Applications dir even when the install then failed.
  • binary's target rename hint becomes <prefix>/bin/<target> for a deleteFile + symLinkAbsolute pair, so ../../../../Users/x/.zshenv deletes that file and repoints it at a binary the cask controls.

Both are now screened at the same choke point, with the sinks (linkCaskBinary, resolveCaskBinaryPath) hardened as well so they don't lean on an upstream guard. cask_font.zig already did exactly this for font stanzas; this brings the other artifact kinds to the same bar.

Adds path_component.isRelativeSubpath for the nested-but-contained case, since binary "bin/tool" and app "Sub/My App.app" are legitimate while isPathComponent would reject them.

post_install sandbox

  • Utils.safe_popen_read skipped both validateArgv and the sandbox-exec fence that system applies — same DSL surface, no confinement. It now takes the identical two gates.
  • ln_s never validated its target, so a formula could mint a doorway out of the keg. Relative targets are resolved against the link's own directory (POSIX semantics) before the boundary check, so ../lib/x from <keg>/bin/y is judged as <keg>/lib/x rather than rejected for containing a ...
  • rm_r and mkdir_p validated only lexically while cp/cp_r already resolved intermediate symlinks. A planted directory symlink turned a keg-local path into an out-of-keg recursive delete. Both now use validateWriteDir. rm_r resolves the parent rather than the leaf, so removing an in-keg symlink still works — deleteTree unlinks a symlinked leaf without following it.

The rm_r test plants the symlink directly rather than via ln_s, because a symlink can also arrive inside a bottle; the delete path has to stand on its own.

Behaviour change worth flagging

A cask whose JSON omits sha256 is now an error. Previously null took the same path as an explicit no_check. Homebrew always emits the field, so absent means malformed or hostile — and a pkg artifact reaches sudo installer -target /. no_check still works. tests/cask_test.zig pinned the old contract and is updated.

Test suite

Three problems, all of which fire on a normal just test:

  • One test set HOMEBREW_GITHUB_API_TOKEN to a sentinel, built the client from the process environ, and issued a real GET to formulae.brew.sh. githubApiToken prefers MALT_GITHUB_TOKEN over the var that test sets, so on a machine with malt's own primary token exported the suite sent a live credential to a third-party host. Both halves of the decision are pure, so they're now asserted directly with no socket. githubTokenApplies becomes pub so the host gate is pinnable — including the look-alike hosts its comment claims are excluded.
  • The remaining live requests (formulae.brew.sh, httpbin.org) are behind MALT_TEST_NETWORK=1. They were unconditional attempts that made the suite depend on a third party's uptime and tripped firewall prompts on sandboxed hosts. They skip rather than fail when not opted in.
  • atomic.maltPrefixOrAbort() reads the ambient environment and falls back to the literal /opt/malt, so a test that forgets a fixture prefix operates on the developer's real install — with createTempDir/cleanupTempDir doing real deletes. Test runs now default MALT_PREFIX to a throwaway, making isolation a property of the harness rather than of each test author remembering. Tests that set their own prefix are unaffected; the one that asserts the unset default still calls unsetenv.

Verification

  • zig build test — full suite green (4900+ tests)
  • ./scripts/lint-spawn-invariants.sh — clean
  • ./scripts/smokes/smoke_security.sh — 8/8

Each fix landed after a regression test that failed against main for the right reason. Tests are colocated in src/*.zig per CONTRIBUTING; the cask contract change is in tests/cask_test.zig.

A tap controls cask JSON, post_install source, and the artifact bytes, so
every string it supplies that reaches a path or a spawn has to be screened.
Four were not:

- `app` and `binary` artifact names were interpolated into destination paths
  unscreened. `parseCask` already calls itself "the one ingestion choke
  point" for token/version; the artifacts went straight through, so the
  unconditional `deleteTree` ahead of the copy, and the `<prefix>/bin`
  symlink, could both be aimed outside their root.
- `Utils.safe_popen_read` skipped the argv0 lint *and* the sandbox-exec
  fence that `system` applies — same DSL surface, no confinement.
- `ln_s` never validated its target, and `rm_r`/`mkdir_p` validated only
  lexically while `cp`/`cp_r` already resolved intermediate symlinks. A
  planted directory symlink turned a keg-local path into an out-of-keg
  recursive delete.
- A cask whose JSON omits `sha256` verified as though it had opted out.
  Homebrew always emits the field, and a `pkg` artifact reaches
  `sudo installer -target /`, so absent now fails closed.

`cask_font.zig` already did exactly this for font stanzas; this brings the
other artifact kinds up to the same bar.
`zig build test` inherits the developer's environment. Three consequences,
all of which fire on a normal `just test`:

- One test set HOMEBREW_GITHUB_API_TOKEN to a sentinel, built the client
  from the *process* environ, and issued a real GET to formulae.brew.sh.
  `githubApiToken` prefers MALT_GITHUB_TOKEN over the var the test sets, so
  on a machine with malt's own primary token exported the suite sent a live
  credential to a third-party host. Both halves of that decision are pure,
  so assert them directly and open no socket. `githubTokenApplies` becomes
  public to make the host gate pinnable — including the look-alike hosts its
  comment claims are excluded.
- The remaining live requests (formulae.brew.sh, httpbin.org) are now behind
  MALT_TEST_NETWORK=1. They were unconditional attempts that made the suite
  depend on a third party's uptime and tripped firewall prompts on sandboxed
  hosts; they degrade to skips instead of failures either way.
- `atomic.maltPrefixOrAbort()` reads the ambient environment and falls back
  to the literal `/opt/malt`, so a test that forgets a fixture prefix
  operates on the developer's real install — with `createTempDir` /
  `cleanupTempDir` doing real deletes. Test runs now default MALT_PREFIX to
  a throwaway, making isolation a property of the harness rather than of
  each test author remembering.
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