fix(exec): route remaining git/ffmpeg/ffprobe spawns through _safe_exec.resolve_required_binary (BE-5358) - #643
Conversation
Every remaining bare-name subprocess spawn in the repo passed `["git", ...]` or `["ffmpeg", ...]`, so Windows' `CreateProcess` searched the current working directory before `$PATH` and would execute a planted `git.exe`/`ffmpeg.exe`. Several of these spawn *after* an `os.chdir()` into a user-supplied repo or custom-node directory, so the attacker-controlled directory is the CWD by construction. Adds `_safe_exec.resolve_required_binary` — the required-binary companion to `resolve_binary`, a thin wrapper over it so the bare-name / fully-qualified / CWD-plant gates cannot drift — and converts every git, ffmpeg and ffprobe spawn to a trusted absolute path. `BinaryNotFoundError` subclasses both `RuntimeError` and `FileNotFoundError` so the call sites that already degraded on a missing binary keep degrading identically instead of starting to crash. `comfy preview`'s duplicate `shutil.which` presence check is folded into the resolution, so both binaries are looked up once and the paths reused.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 2 |
| 🟡 Medium | 4 |
| 🟢 Low | 3 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
Addresses the Cursor review panel on #643. resolve_required_binary collapsed two very different events into one "was not found on PATH" message: git/ffmpeg genuinely not installed, and git/ffmpeg found but refused because the only match was CWD-anchored. That told users with a working binary to go install it, and hid the interesting part — something named git was sitting in their CWD. _safe_exec now reports *why* it refused (BinaryRefusal / BinaryNotFoundError .reason / .candidate) and callers branch on it: - file_utils.list_git_tracked_files: a *refused* git no longer degrades to [], which zip_files reads as "not a git repo" and answers by walking the whole directory — packaging untracked/gitignored files (.env, keys, venvs) into the archive `comfy node publish` uploads. An absent git still degrades as before. `comfy node pack` reports the refusal and aborts. - command/preview: refusal renders the new ffmpeg_untrusted code instead of "install ffmpeg"; both ffprobe/ffmpeg spawns now have timeouts, so a corrupt or crafted media file can't hang `comfy preview` while buffering. - git_utils.git_checkout_tag / checkout_pr: both are documented to return False on failure and both callers rely on it, so a refusal comes back as False rather than escaping as a traceback past their handlers. - install.switch_comfyui_version: checks git up front as version_switch_git_unavailable — otherwise _git_capture's rc=1 made the first `rev-parse --verify <tag>` report "version not found: no tag vX". - registry/config_parser: handler widened to OSError, so a refusal doesn't escape after create_comfynode_config() has already written pyproject.toml. Also option-injection hardening on the git argv: `git clone -- <url> <dir>`, and reject_option_like_ref for `git checkout <rev>`, where a `--` separator cannot help (git scans options before the first positional). git tag/branch both refuse to create a name starting with `-`, and a SHA never does, so nothing legitimate is rejected. Tests: the fake-binary fixtures pinned drive-less \usr\bin\<name>, which _is_fully_qualified rejects on Windows — the exact platform this hardening targets — so every test using them would have failed there. Pinned a drive-qualified path under os.name == "nt".
|
STACKED — merging lands on Resolved all 10 Cursor panel findings in abf558f. The two 🟠 High ones shared a root cause, so they are fixed together: Root cause — Everything downstream branches on that:
Verified: |
ELI-5
On Windows, when a program says "run
git", Windows looks in the folder you happen to be standing in before it looks in the normal places. So if someone drops a fakegit.exeinto a folder and you runcomfyfrom there, you run their fakegit. PR #641 fixed that for the three hardware probes by adding a helper that turns a bare name likenvidia-smiinto a full, trusted path. This PR does the same for everything else that was still asking for a program by bare name —git,ffmpeg,ffprobe. Nothing changes for anyone whosegit/ffmpeglives in a normal place.What changed
1.
comfy_cli/_safe_exec.py— a required-binary companion.resolve_binaryreturnsNone("skip this probe"), which lands cleanly on the probes' degrade-to-Nonecontract.git/ffmpegfailing is usually fatal to the command, so this addsresolve_required_binary(name) -> str, a thin wrapper overresolve_binarythat raisesBinaryNotFoundErrorinstead. Being a wrapper is the point: the_is_bare_name/_is_fully_qualified/is_planted_in_cwdgates live in exactly one place and cannot drift between the two entry points.BinaryNotFoundErrorsubclasses bothRuntimeErrorandFileNotFoundError.FileNotFoundErroris whatsubprocessalready raises today when a bare-name spawn finds nothing, so every call site that already tolerated a missing binary (except (subprocess.SubprocessError, FileNotFoundError)infile_utils,except OSErrorinoutdated, the three-arm handler ininstall) keeps degrading identically rather than starting to crash. That is what makes this a genuinely no-behaviour-change conversion at the tolerant sites, instead of one that silently turns graceful degradation into a traceback.2. Every
gitspawn. All the sites the ticket named, plus three it did not:install.py:520(fetch --tags),install.py:532(tag --list), andinstall.py:677(_git_capture, the shared helper behind the version-switch path).gitgit_utils.pygit_checkout_tag,checkout_pr(9 spawns)gitalready did here (onlyCalledProcessErrorwas caught)cmdline.pyupdate→git pullcommand/install.pyexecute→git checkout,clone_comfyuicommand/install.py_resolve_latest_tag_from_local,_git_capturecommand/outdated.py_git_outputNone(unchanged)file_utils.pylist_git_tracked_files[](unchanged)registry/config_parser.pyinitialize_project_config3.
ffmpeg/ffprobe.command/preview.py's duplicateshutil.which("ffmpeg") and shutil.which("ffprobe")presence check is folded into the resolution, so both binaries are looked up once and the resolved paths are reused for both spawns.build_preview_cmdnow takes a required keyword-onlyffmpeg_bin(no"ffmpeg"default) so no future caller can silently reintroduce the bare name.output/preview.py's best-effort inline previewer usesresolve_binary(not the required variant) because its documented contract is to skip silently — a missing ffmpeg and a refused CWD-planted one degrade the same way, to no preview.Judgment calls
os.chdir, not a module-level_git()wrapper. The ticket suggested one wrapper ingit_utils.pythat "resolves once and prepends the resolved path". I used a per-function local instead, because ingit_utilswhen resolution happens matters: a call-time wrapper resolves after theos.chdir(repo_path), so agitplanted in the caller-supplied repo would win the lookup and be refused — safe, but it turns a plant into a denial of service against a user who has a perfectly goodgiton$PATH. Resolving before the chdir means the plant can neither be executed nor shadow the real binary.test_checkout_tag_spawns_resolved_path_not_the_repo_plantpins exactly this.BinaryNotFoundErroris not a typer error. The ticket offered "RuntimeError/typer error". A typer error would couple the leaf_safe_execmodule to typer and would be wrong at the library-level call sites (file_utils,git_utils) that are not commands.command/preview.pydoes the typer translation itself, at the command boundary, keeping the existingffmpeg_unavailableerror code and message.comfyfrom a directory that is itself an absolute$PATHentry (/usr/bin,C:\Windows\System32) now makesgit/ffmpegunresolvable, so those commands fail instead of running. This is the known false positive already documented inresolve_binary; it is new only in that it now applies togit/ffmpegas well as the probes. For a probe it degrades toNone; forgitit is a clear error message.Not in scope (follow-ups worth filing)
workspace_manager.py:90,100andinstall.py:220usegit.Repo(...), and GitPython spawnsgitby bare name through its ownGIT_PYTHON_GIT_EXECUTABLE(default"git"). Closing that means a globalgit.refresh(<abs path>)at import time, which changes import-time failure behaviour — a distinct, riskier change than this mechanical conversion, and the ticket enumeratessubprocesssites only.utils.py:75(conda) andinstall.py:1134,1162,1178,1201,1210,1307,1314(node,npm,pnpm,npx) are still bare-name. Same vector, outside this ticket's stated scope (git,ffmpeg/ffprobe).Tests
New
tests/comfy_cli/test_safe_exec_call_sites.py(13 cases) andtests/comfy_cli/output/test_inline_preview.py(4 cases), plus new cases intest_safe_exec.pyandcommand/test_preview.py. They share ashutil.whichstub that searches the CWD before$PATH— Windows' actual lookup order — so the planting vector is reproducible on any host. Each converted site asserts two things: the argv handed tosubprocessstarts with the resolved absolute path (never the bare name), and a binary planted directly in the CWD is never spawned (recorder.calls == []). Theos.chdir-then-spawn sites get explicit regression tests, including one that the CWD is restored whengitis unresolvable.Three existing test files were updated because they asserted the bare name:
test_update_version.py'sFakeGitnow dispatches on the argv basename,test_pr.pyasserts the spawned path is absolute and namedgit, andtest_preview.pypasses an explicitffmpeg_bin.Status:
ruff checkclean,ruff format --diffclean (verified against the CI-pinnedruff==0.15.15),pytest— 3710 passed, 37 skipped.Closes BE-5358.