fix(security): close tap-controlled path and sandbox escapes - #790
Open
rustytrees wants to merge 2 commits into
Open
fix(security): close tap-controlled path and sandbox escapes#790rustytrees wants to merge 2 commits into
rustytrees wants to merge 2 commits into
Conversation
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.
This was referenced Jul 29, 2026
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.
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_installsource, 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
parseCaskalready screenstoken/versionthroughfs/path_component.zigand describes itself as "the one ingestion choke point … A compromised tap is the threat". The artifact strings went straight through:appbecomes<app_dir>/<name>and is handed todeleteTreebefore and independently of the copy, so{"app":["../PRECIOUS"]}recursively deleted a directory outside the Applications dir even when the install then failed.binary'stargetrename hint becomes<prefix>/bin/<target>for adeleteFile+symLinkAbsolutepair, so../../../../Users/x/.zshenvdeletes 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.zigalready did exactly this for font stanzas; this brings the other artifact kinds to the same bar.Adds
path_component.isRelativeSubpathfor the nested-but-contained case, sincebinary "bin/tool"andapp "Sub/My App.app"are legitimate whileisPathComponentwould reject them.post_install sandbox
Utils.safe_popen_readskipped bothvalidateArgvand thesandbox-execfence thatsystemapplies — same DSL surface, no confinement. It now takes the identical two gates.ln_snever 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/xfrom<keg>/bin/yis judged as<keg>/lib/xrather than rejected for containing a...rm_randmkdir_pvalidated only lexically whilecp/cp_ralready resolved intermediate symlinks. A planted directory symlink turned a keg-local path into an out-of-keg recursive delete. Both now usevalidateWriteDir.rm_rresolves the parent rather than the leaf, so removing an in-keg symlink still works —deleteTreeunlinks a symlinked leaf without following it.The
rm_rtest plants the symlink directly rather than vialn_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
sha256is now an error. Previouslynulltook the same path as an explicitno_check. Homebrew always emits the field, so absent means malformed or hostile — and apkgartifact reachessudo installer -target /.no_checkstill works.tests/cask_test.zigpinned the old contract and is updated.Test suite
Three problems, all of which fire on a normal
just test:HOMEBREW_GITHUB_API_TOKENto a sentinel, built the client from the process environ, and issued a real GET toformulae.brew.sh.githubApiTokenprefersMALT_GITHUB_TOKENover 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.githubTokenAppliesbecomespubso the host gate is pinnable — including the look-alike hosts its comment claims are excluded.formulae.brew.sh,httpbin.org) are behindMALT_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 — withcreateTempDir/cleanupTempDirdoing real deletes. Test runs now defaultMALT_PREFIXto 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 callsunsetenv.Verification
zig build test— full suite green (4900+ tests)./scripts/lint-spawn-invariants.sh— clean./scripts/smokes/smoke_security.sh— 8/8Each fix landed after a regression test that failed against
mainfor the right reason. Tests are colocated insrc/*.zigper CONTRIBUTING; the cask contract change is intests/cask_test.zig.