Skip to content

fix(brewfile): validate id before joining into filesystem path (path traversal) - #46

Merged
msitarzewski merged 1 commit into
msitarzewski:mainfrom
neodave:fix/brewfile-path-traversal
Jun 4, 2026
Merged

msitarzewski merged 1 commit into
msitarzewski:mainfrom
neodave:fix/brewfile-path-traversal

Conversation

@neodave

@neodave neodave commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

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.

BrewfileId is a bare String, and brewfile_path() joined it straight into a filesystem path with no validation:

fn brewfile_path(dir: &Path, id: &str) -> PathBuf {
    dir.join(format!("{}.Brewfile", id))
}

Five commands — brewfile_read, brewfile_delete, brewfile_install, brewfile_check, brewfile_export — passed a caller-supplied id into this without sanitization. Only dump/import sanitized (via sanitize_label). Because Path::join follows .. and lets an absolute component replace the base entirely, a crafted id escapes brewfiles_dir:

  • id = "../../../../etc/cron.d/evil"…/etc/cron.d/evil.Brewfile
  • id = "/Users/<u>/.ssh/authorized_keys" → absolute id replaces the base

Impact (constrained to a .Brewfile suffix)

  • brewfile_delete → arbitrary file deletion
  • brewfile_read → arbitrary *.Brewfile read, returned to the renderer
  • brewfile_install / brewfile_check → point brew bundle --file= at an attacker-chosen file outside the sandbox

Reachability / severity

Currently bounded: the only IPC caller is the bundled frontend, which has no {@html}/innerHTML sink 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 the id parameter, so this gap was uncovered.

Fix

Validate at the single chokepoint — brewfile_path now runs id through the same [A-Za-z0-9_-], 1–64 char allowlist that sanitize_label already produces, and returns Result. 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 escapes brewfiles_dir. Allowlist (not blocklist) by design — it rejects /, ., .., NUL, and every separator/metacharacter while accepting every id the app legitimately creates.

Testing

  • cargo test592 passed / 0 failed (6 integration tests ignored, require live brew)
  • New brewfile_id_rejects_path_traversal — asserts ../../…, absolute paths, .., dotted names, NUL, spaces, over-length, and empty ids are all rejected
  • New brewfile_id_accepts_sanitize_label_output — asserts legitimate snapshot ids still work
  • Existing brewfile_path_appends_brewfile_suffix updated for the Result signature
  • cargo 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.

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]>
@msitarzewski
msitarzewski merged commit 04bf6c9 into msitarzewski:main Jun 4, 2026
msitarzewski added a commit that referenced this pull request Jun 4, 2026
… 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]>
@msitarzewski

Copy link
Copy Markdown
Owner

Thank you for this, @neodave — and especially for going through SECURITY.md first. Textbook responsible disclosure. 🙏

The fix itself is exactly how I'd want it done: validating at the single brewfile_path chokepoint and making it fallible so the compiler drags every call site through the allowlist — that's the kind of fix that stays fixed as new commands get added. Allowlist-over-blocklist plus the traversal test matrix (.., absolute paths, NUL, over-length, dotted ids) sealed it. Verified 592 tests green and merged.

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. 🍺

msitarzewski added a commit that referenced this pull request Jun 7, 2026
…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]>
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.

2 participants