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

169 lines
7.2 KiB
Python

import hashlib
import os
import requests
from typing import Optional, Any
from collections.abc import Collection, Iterator
from pathlib import Path
from tqdm import tqdm
import folder_paths
import re
def sanitize_filename(filename: str) -> str:
"""Remove characters that are unsafe for filenames."""
# Remove characters that are generally unsafe across file systems
unsafe_chars = r'[<>:"|?*\x00-\x1f]'
sanitized = re.sub(unsafe_chars, '', filename)
# Remove trailing periods and spaces (problematic on Windows)
sanitized = sanitized.rstrip('. ')
return sanitized
def resolve_within_output(*parts: str) -> str:
"""
Join `parts` onto ComfyUI's output directory and return the resolved path,
raising if the result would land outside that directory (e.g. via `..`,
an absolute/drive-qualified component, or a symlink that escapes it).
"""
output_root = os.path.realpath(folder_paths.output_directory)
candidate = os.path.realpath(os.path.join(output_root, *parts))
try:
is_contained = os.path.commonpath([output_root, candidate]) == output_root
except ValueError:
# e.g. candidate resolved onto a different drive on Windows
is_contained = False
if not is_contained:
raise ValueError(f"Resolved path `{candidate}` is outside the output directory `{output_root}`")
return candidate
def get_sha256(file_path: str) -> str:
"""
Given the file path, finds a matching sha256 file, or creates one
based on the headers in the source file
"""
file_no_ext = os.path.splitext(file_path)[0]
hash_file = file_no_ext + ".sha256"
if os.path.exists(hash_file):
try:
with open(hash_file, "r") as f:
return f.read().strip()
except OSError as e:
print(f"ComfyUI-Image-Saver: Error reading existing hash file: {e}")
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
file_size = os.fstat(f.fileno()).st_size
block_size = 1048576 # 1 MB
print(f"ComfyUI-Image-Saver: Calculating sha256 for '{Path(file_path).stem}'")
with tqdm(None, None, file_size, unit="B", unit_scale=True, unit_divisor=1024) as progress_bar:
for byte_block in iter(lambda: f.read(block_size), b""):
progress_bar.update(len(byte_block))
sha256_hash.update(byte_block)
try:
with open(hash_file, "w") as f:
f.write(sha256_hash.hexdigest())
except OSError as e:
print(f"ComfyUI-Image-Saver: Error writing hash to {hash_file}: {e}")
return sha256_hash.hexdigest()
def full_embedding_path_for(embedding: str) -> Optional[str]:
"""
Based on a embedding name, eg: EasyNegative, finds the path as known in comfy, including extension
"""
matching_embedding = get_file_path_match("embeddings", embedding)
if matching_embedding is None:
print(f'ComfyUI-Image-Saver: could not find full path to embedding "{embedding}"')
return None
return folder_paths.get_full_path("embeddings", matching_embedding)
def full_lora_path_for(lora: str) -> Optional[str]:
"""
Based on a lora name, e.g., 'epi_noise_offset2', finds the path as known in comfy, including extension.
"""
# Find the matching lora path
matching_lora = get_file_path_match("loras", lora)
if matching_lora is None:
print(f'ComfyUI-Image-Saver: could not find full path to lora "{lora}"')
return None
return folder_paths.get_full_path("loras", matching_lora)
def full_checkpoint_path_for(model_name: str) -> str:
if not model_name:
return ''
supported_extensions = set(folder_paths.supported_pt_extensions) | {".gguf"}
matching_checkpoint = get_file_path_match("checkpoints", model_name, supported_extensions)
if matching_checkpoint is not None:
return folder_paths.get_full_path("checkpoints", matching_checkpoint)
matching_model = get_file_path_match("diffusion_models", model_name, supported_extensions)
if matching_model:
return folder_paths.get_full_path("diffusion_models", matching_model)
print(f'Could not find full path to checkpoint "{model_name}"')
return ''
def get_file_path_iterator(folder_name: str, supported_extensions: Optional[Collection[str]] = None) -> Iterator[Path]:
"""
Returns an iterator over valid file paths for the specified model folder.
"""
if supported_extensions is None:
return (Path(x) for x in folder_paths.get_filename_list(folder_name))
else:
return custom_file_path_generator(folder_name, supported_extensions)
def custom_file_path_generator(folder_name: str, supported_extensions: Collection[str]) -> Iterator[Path]:
"""
Generator function for file paths, allowing for a customized extension check.
"""
model_paths = folder_paths.folder_names_and_paths.get(folder_name, [[], set()])[0]
for path in model_paths:
if os.path.exists(path):
base_path = Path(path)
for root, _, files in os.walk(path):
root_path = Path(root).relative_to(base_path)
for file in files:
file_path = root_path / file
if file_path.suffix.lower() in supported_extensions:
yield file_path
def get_file_path_match(folder_name: str, file_name: str, supported_extensions: Optional[Collection[str]] = None) -> Optional[str]:
supported_extensions_fallback = supported_extensions if supported_extensions is not None else folder_paths.supported_pt_extensions
file_path = Path(file_name)
# first try full path match, then fallback to just name match, matching the extension if appropriate
if file_path.suffix.lower() not in supported_extensions_fallback:
matching_file_path = next((p for p in get_file_path_iterator(folder_name, supported_extensions) if p.with_suffix('') == file_path), None)
matching_file_path = (matching_file_path if matching_file_path is not None else
next((p for p in get_file_path_iterator(folder_name, supported_extensions) if p.stem == file_path.name), None))
else:
matching_file_path = next((p for p in get_file_path_iterator(folder_name, supported_extensions) if p == file_path), None)
matching_file_path = (matching_file_path if matching_file_path is not None else
next((p for p in get_file_path_iterator(folder_name, supported_extensions) if p.name == file_path.name), None))
return str(matching_file_path) if matching_file_path is not None else None
def http_get_json(url: str) -> dict[str, Any] | None:
try:
response = requests.get(url, timeout=300)
except requests.exceptions.Timeout:
print(f"ComfyUI-Image-Saver: HTTP GET Request timed out for {url}")
return None
except requests.exceptions.ConnectionError as e:
print(f"ComfyUI-Image-Saver: Warning - Network connection error for {url}: {e}")
return None
if not response.ok:
print(f"ComfyUI-Image-Saver: HTTP GET Request failed with error code: {response.status_code}: {response.reason}")
return None
try:
return response.json()
except ValueError as e:
print(f"ComfyUI-Image-Saver: HTTP Response JSON error: {e}")
return None