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: |
|
Blocked on its base, not on anything in this diff. GitHub reports this PR So the order is: rebase #641 → merge it → this retargets to Reading the description in the meantime, two things I'd flag now so they don't need a second round:
Ping me once the stack is rebased. |
skishore23
left a comment
There was a problem hiding this comment.
Approving. Merges CLEAN against current main, and the two load-bearing design decisions both hold up under direct testing. Merge #641 first.
BinaryNotFoundError's dual inheritance does what it claims
This is what makes the conversion genuinely behaviour-preserving at the tolerant sites, so I checked it rather than reading it:
MRO: BinaryNotFoundError -> RuntimeError -> FileNotFoundError -> OSError -> Exception
except (subprocess.SubprocessError, FileNotFoundError) catches: True # file_utils
except OSError catches: True # outdated
except RuntimeError catches: True
So every existing handler keeps degrading identically instead of starting to traceback. Inheriting from FileNotFoundError specifically — because that's what subprocess already raises on a bare-name miss — is the right insight; a plain RuntimeError would have silently converted graceful degradation into crashes at four call sites.
Resolving before os.chdir is the subtle win, and it works
This is the part most likely to be "simplified" into a module-level wrapper later, so it's worth recording that the reasoning is empirically correct. I planted a git inside a caller-supplied repo directory and stubbed shutil.which to Windows' actual CWD-first order:
raised : (none — operation succeeded)
num spawns : 2
any spawn == the plant: False
all spawns absolute : True
argv[0] : /usr/bin/git
cwd restored : True
The plant is neither executed nor allowed to cause a denial of service — the real /usr/bin/git runs and the checkout succeeds. A call-time wrapper resolving after the chdir would have found the plant and refused, turning someone else's plant into a failure for a user with a perfectly good git. Good call deviating from the ticket's suggested design, and test_checkout_tag_spawns_resolved_path_not_the_repo_plant pins it.
The false positive is real, deliberate, and correctly fail-closed
Reproduced it:
cwd = /usr/bin (a genuine absolute PATH entry)
resolve_binary('git') -> None
resolve_required_binary('git') -> BinaryNotFoundError:
refusing to run 'git': the only match on PATH (/usr/bin/git) is in the …
I was going to suggest exempting directories that are explicit absolute $PATH entries to remove this — but the is_planted_in_cwd docstring already anticipates and rejects that, correctly: on Windows the implicit current-directory search is indistinguishable from an explicit entry, and PATH="$(pwd):$PATH" wrappers are themselves the vector. Weakening it there would blunt the fix on the exact platform it targets. Fail-closed is right; the error message explains itself well.
Still worth a release-note line, since this is the one user-visible change: running comfy from a directory that is itself a $PATH entry now fails git/ffmpeg commands rather than running them.
Other verification
- 172 passed across
test_safe_exec.py,test_safe_exec_call_sites.py,output/test_inline_preview.py,command/test_preview.py,github/test_pr.py,test_update_version.py. - Making
build_preview_cmd'sffmpeg_bina required keyword-only arg with no"ffmpeg"default is the right shape — it makes reintroducing the bare name aTypeErrorat import/call time rather than a silent regression. - Using
resolve_binary(not the required variant) inoutput/preview.pymatches that previewer's documented skip-silently contract — a missing ffmpeg and a refused planted one degrade the same way. - Catching the three sites the ticket didn't name (
install.py:520,:532,:677) is the kind of thing that makes a mechanical sweep actually complete.
Unrelated note: import comfy_cli.git_utils as the first import raises a circular-import ImportError — but that reproduces identically on origin/main, so it's pre-existing and not this PR's problem. Might be worth its own ticket.
The GitPython and conda/npm/npx deferrals are correctly reasoned — git.refresh() changes import-time failure behaviour and deserves its own risk assessment rather than riding along here.
f93f08e
into
matt/be-5357-cuda-detect-absolute-path
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.