fix(brewfile): validate id before joining into filesystem path (path traversal) - #46
Conversation
The read/delete/install/check/export Brewfile commands took the `id`
straight from the IPC boundary and built the target path with
`dir.join(format!("{id}.Brewfile"))`. Because `Path::join` follows `..`
and lets an absolute component replace the base, a crafted id such as
`../../../../etc/cron.d/evil` or `/Users/x/.ssh/authorized_keys` escaped
`brewfiles_dir` — enabling arbitrary-file read/delete (limited to a
`.Brewfile` suffix) and pointing `brew bundle --file=` at an attacker
-chosen file. Only `dump`/`import` sanitized their ids (via
`sanitize_label`); the other five did not.
Fix at the single chokepoint: `brewfile_path` now validates the id
against the same `[A-Za-z0-9_-]` (1–64 char) allowlist `sanitize_label`
produces and returns `Result`, so every current and future caller is
forced by the type system to pass validation. Adds tests covering both
the accepted-id and traversal-rejection cases.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
… PR #46) Mirrors @neodave's Tauri path-traversal hardening on the native side. SnapshotStore.path(forID:) joined the id straight into a path; make it throwing and run the same [A-Za-z0-9_-] (1–64) allowlist (SnapshotStore.validateID) at that single chokepoint, so delete/export/importFile/dumpTarget all route through validation. Defense-in-depth, not a live fix: native has no untrusted IPC caller — ids only come from sanitizeLabel or a directory scan — so unlike Tauri's renderer-facing IPC there's no reachable traversal. Kept in parity regardless. swift build clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Thank you for this, @neodave — and especially for going through The fix itself is exactly how I'd want it done: validating at the single Your severity writeup was appreciated too — framing it as a latent primitive rather than overstating it made the review easy to trust. One bonus: it prompted me to audit the native macOS build (same Brewfile storage layer) and mirror the same id allowlist there for parity — so your report hardened both apps, even though the native side has no equivalent untrusted caller. Genuinely great first contribution. Thanks again. 🍺 |
…neodave - README/SECURITY/CONTRIBUTING now document BOTH builds (Tauri + native Swift/ SwiftUI): a "Two builds" comparison + parity rule, native build/test steps, dual-build security posture, title → "Brew Browser", SwiftUI badge. - New dashboard screenshots (Tauri + native) — docs/ and landing/. - SECURITY.md Hall of fame: credit @neodave for the Brewfile/snapshot path-traversal fix (#46), defended in both builds. - Landing page (landing/index.html) reworked for dual-build + published live to brew-browser.zerologic.com. - Fix landing/README.md footgun: a bare `rsync --delete` to the web root would wipe updater.json (the root also serves the Tauri updater); genericize the host out of the committed file. - memory-bank: task 11 docs/landing section, progress, activeContext. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Summary
Fixes a path-traversal vulnerability in the Brewfile IPC commands. Reported privately first per
SECURITY.md; opening this PR at the maintainer's request.BrewfileIdis a bareString, andbrewfile_path()joined it straight into a filesystem path with no validation:Five commands —
brewfile_read,brewfile_delete,brewfile_install,brewfile_check,brewfile_export— passed a caller-suppliedidinto this without sanitization. Onlydump/importsanitized (viasanitize_label). BecausePath::joinfollows..and lets an absolute component replace the base entirely, a craftedidescapesbrewfiles_dir:id = "../../../../etc/cron.d/evil"→…/etc/cron.d/evil.Brewfileid = "/Users/<u>/.ssh/authorized_keys"→ absolute id replaces the baseImpact (constrained to a
.Brewfilesuffix)brewfile_delete→ arbitrary file deletionbrewfile_read→ arbitrary*.Brewfileread, returned to the rendererbrewfile_install/brewfile_check→ pointbrew bundle --file=at an attacker-chosen file outside the sandboxReachability / severity
Currently bounded: the only IPC caller is the bundled frontend, which has no
{@html}/innerHTMLsink and ships a strict CSP, so there is no live exploit path today. It's a latent arbitrary-file-read/delete primitive that would become live the moment any HTML-injection sink is introduced. The existing audit's H2 hardened the import/export paths but not theidparameter, so this gap was uncovered.Fix
Validate at the single chokepoint —
brewfile_pathnow runsidthrough the same[A-Za-z0-9_-], 1–64 char allowlist thatsanitize_labelalready produces, and returnsResult. Making the function fallible forces the compiler to route all 7 call sites through validation, so no current or future command can construct a Brewfile path that escapesbrewfiles_dir. Allowlist (not blocklist) by design — it rejects/,.,.., NUL, and every separator/metacharacter while accepting every id the app legitimately creates.Testing
cargo test— 592 passed / 0 failed (6 integration tests ignored, require livebrew)brewfile_id_rejects_path_traversal— asserts../../…, absolute paths,.., dotted names, NUL, spaces, over-length, and empty ids are all rejectedbrewfile_id_accepts_sanitize_label_output— asserts legitimate snapshot ids still workbrewfile_path_appends_brewfile_suffixupdated for theResultsignaturecargo clippy/cargo fmt— the touched file is clean (any other warnings are pre-existing and unrelated to this change)Scoped to one file:
src-tauri/src/commands/brewfile.rs.