Files
Alexopus bc798df250 Fix path traversal in ImageSaver output path/filename handling
`path` and `filename` are free-form strings that can come from a
workflow's JSON, not just a person typing into the local UI. Only the
final path segment was ever sanitized (make_pathname/sanitize_filename),
so a value like `../../../etc` or an absolute path could make
ImageSaver.save_images() create directories and write the image/JSON
sidecar outside ComfyUI's output directory (CWE-22/CWE-73). This is
what caused v1.24.0 to be banned on the Comfy Registry
(Comfy-Org/registry-backend#206).

Add utils.resolve_within_output(), which joins path pieces onto
folder_paths.output_directory, resolves symlinks, and rejects the
result if it escapes that directory. Apply it everywhere
ImageSaver.save_images() turns `path`/`filename` into a filesystem
path: the output directory itself, the final image target, and the
optional workflow-JSON sidecar. Nested relative subfolders (e.g.
`portraits/2026-08-27`) still work; `..`, absolute paths, and
symlink escapes are now rejected before anything is created.

Covered by a new tests/ suite (tests/test_paths.py), since exercising
nodes.py requires stubbing ComfyUI's runtime (see tests/conftest.py).

Fixes alexopus/ComfyUI-Image-Saver#138
2026-08-27 20:52:13 +02:00

114 lines
3.5 KiB
Python

"""
Regression tests for the output-path/filename containment fix in nodes.py
(ImageSaver.save_images) and its underlying primitive, utils.resolve_within_output.
See conftest.py for how nodes.py/utils.py get imported without a full ComfyUI
install.
"""
import numpy as np
import pytest
import folder_paths
from image_saver_under_test.nodes import ImageSaver, Metadata
from image_saver_under_test.utils import resolve_within_output
@pytest.fixture(autouse=True)
def output_dir(tmp_path, monkeypatch):
monkeypatch.setattr(folder_paths, "output_directory", str(tmp_path))
return tmp_path
class FakeTensor:
"""Duck-types the .cpu().numpy() calls ImageSaver.save_images makes on IMAGE tensors."""
def __init__(self, array):
self._array = array
def cpu(self):
return self
def numpy(self):
return self._array
def make_image(size=8):
return FakeTensor(np.random.rand(size, size, 3))
def make_metadata(**overrides):
defaults = dict(
modelname="", positive="", negative="", width=8, height=8, seed=0,
steps=1, cfg=1.0, sampler_name="euler", scheduler_name="normal",
denoise=1.0, clip_skip=0, custom="", additional_hashes="",
ckpt_path="", a111_params="test params", final_hashes="",
)
defaults.update(overrides)
return Metadata(**defaults)
def call_save_images(path, filename="test", extension="png", save_workflow_as_json=False):
return ImageSaver.save_images(
images=[make_image()],
filename_pattern=filename,
extension=extension,
path=path,
quality_jpeg_or_webp=100,
lossless_webp=True,
optimize_png=False,
prompt=None,
extra_pnginfo={"workflow": {"nodes": []}},
save_workflow_as_json=save_workflow_as_json,
embed_workflow=False,
counter=0,
time_format="%Y-%m-%d-%H%M%S",
metadata=make_metadata(),
)
@pytest.mark.parametrize("path", ["", "portraits", "portraits/2026-08-27", "a/b/c"])
def test_accepts_nested_paths_within_output_dir(path, output_dir):
call_save_images(path)
written = list(output_dir.rglob("*.png"))
assert len(written) == 1
assert output_dir in written[0].parents
@pytest.mark.parametrize("path", ["../escape", "../../escape", "/etc/escape"])
def test_rejects_traversal_via_path(path, output_dir):
with pytest.raises(ValueError):
call_save_images(path)
assert list(output_dir.rglob("*.png")) == []
def test_rejects_traversal_via_filename(output_dir):
with pytest.raises(ValueError):
call_save_images(path="safe", filename="../../../escape")
assert list(output_dir.rglob("*.png")) == []
def test_rejects_traversal_via_json_sidecar(output_dir):
with pytest.raises(ValueError):
call_save_images(path="safe", filename="../../../escape", save_workflow_as_json=True)
assert list(output_dir.rglob("*.json")) == []
def test_symlink_escape_is_rejected(output_dir):
outside = output_dir.parent / "outside"
outside.mkdir()
(output_dir / "linked").symlink_to(outside)
with pytest.raises(ValueError):
call_save_images(path="linked")
assert list(outside.iterdir()) == []
def test_resolve_within_output_accepts_nested_relative(output_dir):
resolved = resolve_within_output("a/b")
assert resolved == str((output_dir / "a" / "b").resolve())
@pytest.mark.parametrize("bad", ["../escape", "/etc/escape"])
def test_resolve_within_output_rejects_escape(bad, output_dir):
with pytest.raises(ValueError):
resolve_within_output(bad)