Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions comfy_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,31 +169,47 @@ def extract_tarball(
shutil.rmtree(extractPath, ignore_errors=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CriticalextractPath is derived from the archive's own first member (old_name = info.name.split("/")[0]) and handed to shutil.rmtree(..., ignore_errors=True) before any filter runs, so filter="data" cannot protect it: a tarball whose first member is ../evil yields old_name == "..", and Path.with_name("..") is accepted, so this recursively deletes the parent of the tarball's directory with the errors swallowed (for comfy standalone --rehydrate, which passes python.tgz, that is the parent of the user's cwd). An absolute first member such as /etc/passwd instead yields old_name == "" and crashes in with_name. Validate old_name — reject "", ".", "..", and any name containing a separator — before using it as an rmtree target.

Raised by 5 of 8 reviewers (claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max adversarial, kimi-k3-max adversarial, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max edge-case).

shutil.rmtree(outPath, ignore_errors=True)

# Both extraction paths below use the stdlib "data" filter so a member with an
# absolute path or a `../` traversal is rejected instead of being written
# wherever it points (CVE-2007-4559).
#
# This used to be skipped because of https://github.com/python/cpython/issues/107845,
# where data_filter resolved symlink targets against the destination root rather
# than against the directory holding the link, and so falsely raised
# LinkOutsideDestinationError on valid archives. That was a false-rejection bug,
# never an escape, and it was fixed in 3.10.13 / 3.11.5 / 3.12.0rc2 (2023-08-24).
# The only affected releases in our range are 3.10.12 and 3.11.4 — and since
# `extractall(filter=...)` does not exist before 3.10.12 at all, that is the whole

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The version reasoning falls short in both directions: extractall(filter=...) not existing before 3.10.12/3.11.4 is not a reason to dismiss that range but a hard TypeError on every extraction, and pyproject.toml still allows >=3.10; meanwhile 3.10.12 is the system Python on Ubuntu 22.04 LTS, so the #107845 false rejection would break the primary comfy standalone path there, not an exotic one. Raising requires-python to >=3.10.13 / >=3.11.5 (or gating on sys.version_info) closes both instead of documenting them. Worth noting in the comment as well that the data filter itself had genuine escape fixes in 3.10.18/3.11.13/3.12.11/3.13.4 (CVE-2025-4517).

Raised by 6 of 8 reviewers (gpt-5.6-sol-max adversarial, kimi-k3-max adversarial, gpt-5.6-sol-max edge-case, gemini-3.1-pro edge-case, claude-opus-5-thinking-max edge-case, kimi-k3-max edge-case).

# window. On those two, the worst case is a loud error rather than a silent escape.
if not show_progress:
with tarfile.open(inPath) as tar:
tar.extractall(filter=None)
tar.extractall(filter="data")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — Neither extractall call passes path=, so members are unpacked into the process CWD and the data filter anchors its containment check there instead of at the intended destination: a member like payload/../evil.txt (or a plain .bashrc) passes the filter, is written beside rather than inside the payload tree, and survives the later shutil.move(extractPath, outPath). The same implicit assumption that CWD equals inPath.parent means any caller passing an absolute inPath from another directory hits FileNotFoundError in that move. Extract into an explicit staging directory via path= so the filter's boundary is the destination you actually intend.

Raised by 4 of 8 reviewers (claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max adversarial, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max edge-case).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not this PR's fault (pre-existing in both paths), but worth a follow-up: extractall is called without path=, so extraction lands in the CWD, while extractPath is computed next to the tarball (inPath.with_name(...)). When CWD != the tarball's parent directory, the subsequent shutil.move fails with FileNotFoundError — I hit this in practice while testing. Current callers happen to satisfy the assumption, but passing path=inPath.parent to extractall would make the function self-consistent. Happy to see that as a separate PR.

shutil.move(extractPath, outPath)
return

fileSize = inPath.stat().st_size

_size = 0

with _tarball_progress("extracting tarball...", fileSize) as (barProg, barTask, pathProg, pathTask):

def _filter(tinfo: tarfile.TarInfo, _path: PathLike):
nonlocal _size
pathProg.update(pathTask, description=tinfo.path)
barProg.advance(barTask, _size)
_size = tinfo.size

# TODO: ideally we'd use data_filter here, but it's busted: https://github.com/python/cpython/issues/107845
# return tarfile.data_filter(tinfo, _path)
return tinfo
def _reporting_members(tar: tarfile.TarFile):
"""Yield every member, driving the progress bars as we go.

Progress reporting used to ride on the ``filter`` argument, which
meant the extraction filter had to be a custom callable that
returned members unmodified — silently disabling the CVE-2007-4559
checks. The two concerns are separable: ``members`` drives the UI
and ``filter`` stays the stdlib ``"data"`` filter.
"""
size = 0
for tinfo in tar:
pathProg.update(pathTask, description=tinfo.path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowtinfo.path is archive-controlled and is passed unescaped as a description rendered by progress.TextColumn("{task.description}"), which parses console markup by default. A perfectly legal member name like foo[/bold].txt makes Text.from_markup raise MarkupError during the Live refresh, and [link=...] lets the archive emit OSC-8 escapes into the user's terminal — neither is blocked by the data filter, so a crafted archive breaks the progress path while succeeding with show_progress=False. Wrap the name in rich.markup.escape() or build the column with markup=False.

Raised by 3 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k3-max adversarial, claude-opus-5-thinking-max edge-case).

barProg.advance(barTask, size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — The bar's total is the compressed archive size (fileSize = inPath.stat().st_size) while each advance uses the member's uncompressed tinfo.size, so the bar overruns its total on any compressible tarball. The mismatch predates this change, but since the reporting loop is being rewritten here it is the natural place to make the units agree (e.g. base the total on the members' uncompressed sizes).

Raised by 1 of 8 reviewers (gemini-3.1-pro edge-case).

size = tinfo.size
yield tinfo
barProg.advance(barTask, size)

with tarfile.open(inPath) as tar:
tar.extractall(filter=_filter)
barProg.advance(barTask, _size)
tar.extractall(members=_reporting_members(tar), filter="data")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mediumfilter="data" introduces a mid-extraction failure mode the old no-op callable never had: with the default errorlevel=1, a rejected member N aborts extractall after members 1..N-1 are already on disk, and the shutil.rmtree(outPath) at the top of the function has already deleted the previous install. Nothing cleans up, so a hostile (or falsely rejected) archive leaves a half-extracted tree behind and no outPath. Wrap both extraction calls in try/except, remove the partial tree, then re-raise.

Raised by 5 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k3-max adversarial, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max edge-case, kimi-k3-max edge-case).

pathProg.update(pathTask, description="")

shutil.move(extractPath, outPath)
Expand Down
77 changes: 77 additions & 0 deletions tests/comfy_cli/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import tarfile
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -82,3 +83,79 @@ def test_create_and_extract(self, tmp_path, monkeypatch):

assert (dest / "hello.txt").read_text() == "hello world"
assert (dest / "sub" / "nested.txt").read_text() == "nested content"


def _write_member(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tinfo = tarfile.TarInfo(name)
tinfo.size = len(data)
tar.addfile(tinfo, io.BytesIO(data))


def _write_symlink(tar: tarfile.TarFile, name: str, target: str) -> None:
tinfo = tarfile.TarInfo(name)
tinfo.type = tarfile.SYMTYPE
tinfo.linkname = target
tar.addfile(tinfo)


class TestExtractTarballFiltering:
"""Regression tests for CVE-2007-4559 (see issue #725).

``extract_tarball`` extracts into the current working directory, so a member
named ``../evil.txt`` lands one level above it unless the stdlib ``data``
filter rejects the archive.
"""

@staticmethod
def _traversal_tarball(workdir):
tarball = workdir / "payload.tgz"
with tarfile.open(tarball, "w:gz") as tar:
_write_member(tar, "payload/keep.txt", b"benign")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — This traversal test only passes because the benign member is written first: old_name becomes "payload", so the pre-extraction rmtree is harmless and the data filter catches ../evil.txt later. Member order is attacker-controlled, and putting the traversal member first makes old_name == ".." and wipes the grandparent directory before the filter ever runs. Add a case with the traversal member as the first entry so the test covers the ordering an attacker would choose.

Raised by 2 of 8 reviewers (claude-opus-5-thinking-max edge-case, claude-opus-5-thinking-max adversarial).

_write_member(tar, "../evil.txt", b"pwned")
return tarball

@pytest.mark.parametrize("show_progress", [False, True])
def test_rejects_path_traversal_member(self, tmp_path, monkeypatch, show_progress):
"""Both extraction paths must refuse to write outside the destination."""
workdir = tmp_path / "work"
workdir.mkdir()
monkeypatch.chdir(workdir)

tarball = self._traversal_tarball(workdir)
escaped = tmp_path / "evil.txt"

rejection = None
with patch("comfy_cli.utils.Live"):
try:
extract_tarball(tarball, workdir / "out", show_progress=show_progress)
except tarfile.FilterError as exc:
rejection = exc

assert not escaped.exists(), f"traversal member escaped the extraction directory: {escaped}"
assert rejection is not None, "the traversal member was extracted instead of being rejected"

@pytest.mark.parametrize("show_progress", [False, True])
def test_allows_internal_symlinks(self, tmp_path, monkeypatch, show_progress):
"""Control: the filter must not reject the layout real payloads use.

python-build-standalone tarballs (the only thing ``StandalonePython``
extracts) are full of relative symlinks such as ``bin/python3 ->
python3.12``. Those stay inside the destination and must survive.
"""
workdir = tmp_path / "work"
workdir.mkdir()
monkeypatch.chdir(workdir)

tarball = workdir / "python.tgz"
with tarfile.open(tarball, "w:gz") as tar:
_write_member(tar, "python/bin/python3.12", b"#!/bin/sh\n")
_write_symlink(tar, "python/bin/python3", "python3.12")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — This control test is meant to back the comment's claim that the 3.10.12/3.11.4 data_filter bug is tolerable, but same-directory targets (python3 -> python3.12) never triggered cpython#107845 — joining such a linkname onto the destination root still lands inside it. The false LinkOutsideDestinationError only fires for targets that ascend, e.g. python/bin/x -> ../lib/y, so this test stays green on the affected interpreters regardless. Add an ascending relative symlink member so it actually exercises the window the new comment describes.

Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k3-max edge-case).

_write_symlink(tar, "python/bin/python", "python3")

dest = workdir / "out"
with patch("comfy_cli.utils.Live"):
extract_tarball(tarball, dest, show_progress=show_progress)

assert (dest / "bin" / "python3.12").read_bytes() == b"#!/bin/sh\n"
assert (dest / "bin" / "python3").is_symlink()
assert (dest / "bin" / "python").is_symlink()
Loading