Files
CraftopiaStudio-ComfyUI-Cra…/craftkit_folder_guard.py
T
rubz dca216b0a2 Confine SmartBatchResize's input_folder to approved folders (1.1.4)
The registry's HIGH-severity finding was never the COM/folder-picker code
we suspected in 1.1.1-1.1.3 — it's smart_batch_resize.py's input_folder
widget accepting any path and writing to it, an unconfined filesystem
write reachable via unauthenticated POST /prompt. Found via the
undocumented ?include_status_reason=true parameter on the versions API.

Adds craftkit_folder_guard.py (pattern adapted from Pixaroma's
_path_guard.py): a folder is only usable if it's inside ComfyUI's own
input/output/temp directories, or the user picked it in the native OS
folder dialog at some point (the click-OK happens outside anything a
request can reach), or allow_any is hand-edited into the config.

Also fixes a PowerShell console window flashing open behind the folder
dialog (missing CREATE_NO_WINDOW).
2026-08-31 13:52:04 +02:00

287 lines
10 KiB
Python

"""Containment guard for nodes that take a free-text folder path (SmartBatchResize's
`input_folder`).
WHY THIS EXISTS
----------------
ComfyUI's /prompt endpoint is unauthenticated, so any STRING widget value is
effectively attacker-controlled: a malicious webpage open in the same browser
can POST a crafted prompt to http://127.0.0.1:8188/prompt via CSRF. A node
that reads/writes an arbitrary user-supplied folder is therefore an
unconfined filesystem read/write reachable without any real authorization —
exactly what got CraftKit 1.0.1-1.1.1 banned from the registry (HIGH severity,
see smart_batch_resize.py:246-263 in the pre-fix version).
THE FIX: a folder is only usable if one of these holds:
a) it's inside ComfyUI's own input/output/temp directories, or
b) the user picked it in the native OS folder dialog at some point (the
click-OK happens in the operating system, outside anything a request
can reach — that's the one signal we have that a human authorized it), or
c) "allow_any": true was hand-edited into the config file (never settable
via any route — a route that could flip it would be a one-call bypass).
Config lives at <ComfyUI user dir>/craftkit/allowed_folders.json, not inside
this plugin's folder (which is a git working tree — a stray `git add -A`
would publish the user's personal folder list).
Pattern adapted from Pixaroma's ComfyUI-Pixaroma/nodes/_path_guard.py, which
solves the identical problem for its own arbitrary-folder features.
"""
from __future__ import annotations
import json
import os
import sys
import threading
_LOCK = threading.Lock()
_CONFIG_NAME = "allowed_folders.json"
def is_path_under(child: str, *parents: str) -> bool:
"""True iff `child` resolves inside ANY of `parents`.
Strict realpath-on-both-sides is the primary (and usually only) test. A
lexical (abspath) fallback runs only when the strict comparison can't even
be made because the two sides land on different drives (e.g. a models
folder junctioned onto another disk) — os.path.commonpath raises
ValueError in that case rather than just returning False.
"""
if not child or not isinstance(child, str):
return False
try:
child_real = os.path.realpath(child)
child_abs = os.path.abspath(child)
except (OSError, ValueError, TypeError):
return False
for p in parents:
if not p or not isinstance(p, str):
continue
cross_drive = False
try:
parent_real = os.path.realpath(p)
if os.path.commonpath([child_real, parent_real]) == parent_real:
return True
except ValueError:
cross_drive = True
except (OSError, TypeError):
continue
if not cross_drive:
continue
try:
parent_abs = os.path.abspath(p)
if os.path.commonpath([os.path.normcase(child_abs), os.path.normcase(parent_abs)]) == os.path.normcase(parent_abs):
return True
except (OSError, ValueError, TypeError):
continue
return False
def _norm_raw(p) -> str:
if not p or not isinstance(p, str):
return ""
return p.strip().strip('"').strip("'").strip()
def unc_like(p) -> bool:
r"""True for a Windows UNC path (\\server\share, //server/share).
Purely lexical — must not touch the filesystem. Merely calling
os.path.realpath/isdir on a UNC path makes Windows open an SMB connection
and hand over an NTLM hash before any containment check gets to run, so a
UNC value has to be judged before it's resolved, not after.
"""
return _norm_raw(p).replace("/", "\\").startswith("\\\\")
def is_unc_root(p) -> bool:
r"""True when `p` is exactly a UNC share root (\\server\share, no subpath).
os.path.commonpath can't compare such a root against a path inside it
(splitdrive gives an empty, "relative"-looking remainder), so it has to be
matched by prefix instead — see _lexically_under_any.
"""
try:
drive, rest = os.path.splitdrive(os.path.abspath(p))
except (OSError, ValueError, TypeError):
return False
return drive.replace("/", "\\").startswith("\\\\") and rest.strip("\\/") == ""
def _lexically_under_any(child: str, parents) -> bool:
"""Prefix test on abspath only — no realpath, so no filesystem access.
Weaker than is_path_under (a symlink could fool it); used only to accept a
UNC path under a share root the user already approved via the picker."""
child = _norm_raw(child)
if not child:
return False
try:
c = os.path.normcase(os.path.abspath(child))
except (OSError, ValueError, TypeError):
return False
for p in parents or ():
p = _norm_raw(p)
if not p:
continue
try:
pa = os.path.normcase(os.path.abspath(p)).rstrip("\\/")
except (OSError, ValueError, TypeError):
continue
if pa and (c == pa or c.startswith(pa + os.sep) or c.startswith(pa + "/")):
return True
return False
def comfy_roots() -> list:
"""ComfyUI's own input/output/temp directories, whichever resolve.
folder_paths is imported lazily so this module stays importable outside a
live ComfyUI process (e.g. under pytest).
"""
roots = []
try:
import folder_paths
except Exception:
return roots
for getter in ("get_input_directory", "get_output_directory", "get_temp_directory"):
try:
fn = getattr(folder_paths, getter, None)
v = fn() if callable(fn) else None
if v:
roots.append(v)
except Exception:
continue
return roots
def _config_path() -> str:
base = None
try:
import folder_paths
base = folder_paths.get_user_directory()
except Exception:
base = None
if not base:
base = os.path.join(os.path.expanduser("~"), ".craftkit")
return os.path.join(base, "craftkit", _CONFIG_NAME)
def _read_config() -> dict:
"""Never raises — a missing or damaged file means 'nothing extra is
allowed', never an exception mid-run. `damaged` lets remember_folder
refuse to write, rather than silently overwriting a corrupt-but-recoverable
config and wiping every previously approved folder."""
out = {"folders": [], "allow_any": False, "damaged": False}
path = _config_path()
if not os.path.exists(path):
return out
try:
# utf-8-sig transparently strips a BOM if present (e.g. a file written
# by PowerShell's `Set-Content -Encoding utf8`, or hand-edited in an
# older Notepad) — plain utf-8 would otherwise fail to parse it and
# this file would wrongly read as "damaged".
with open(path, "r", encoding="utf-8-sig") as f:
obj = json.load(f)
except Exception:
out["damaged"] = True
return out
if not isinstance(obj, dict):
out["damaged"] = True
return out
folders = obj.get("folders")
if isinstance(folders, list):
out["folders"] = [x for x in folders if isinstance(x, str) and x.strip()]
elif folders is not None:
out["damaged"] = True
out["allow_any"] = obj.get("allow_any") is True
return out
def dialog_available() -> bool:
if os.name == "nt" or sys.platform == "darwin":
return True
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
def remember_folder(path: str) -> bool:
"""Add a folder the user picked IN THE NATIVE OS DIALOG to the allowlist.
Call this ONLY from the native-picker route (__init__.py's browse_folder),
never from anywhere an ordinary request body can reach — the dialog's
click-OK is the one signal that a human, not a request, chose this path.
"""
if not path or not isinstance(path, str):
return False
try:
real = os.path.realpath(path)
except (OSError, ValueError, TypeError):
return False
if not os.path.isdir(real):
return False
with _LOCK:
cfg = _read_config()
if cfg["damaged"]:
return False
if is_path_under(real, *cfg["folders"]):
return True # already covered by an approved parent
kept = [f for f in cfg["folders"] if not is_path_under(f, real)]
kept.append(real)
path = _config_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"folders": kept, "allow_any": cfg["allow_any"]}, f, indent=2)
os.replace(tmp, path)
except Exception:
return False
return True
def prescreen(raw) -> bool:
"""Cheap no-filesystem screen to run BEFORE anything calls realpath/isdir
on an untrusted folder string. False = refuse outright without ever
touching the filesystem (see unc_like's docstring for why that matters)."""
cfg = _read_config()
if cfg["allow_any"]:
return True
if unc_like(raw):
return _lexically_under_any(raw, cfg["folders"])
return True
def folder_allowed(path: str) -> bool:
"""True when `path` is somewhere SmartBatchResize may read/write."""
if not path or not isinstance(path, str):
return False
if not prescreen(path):
return False
cfg = _read_config()
if cfg["allow_any"]:
return True
roots = comfy_roots()
if roots and is_path_under(path, *roots):
return True
folders = cfg["folders"]
if not folders:
return False
if is_path_under(path, *folders):
return True
unc_roots = [f for f in folders if is_unc_root(f)]
return bool(unc_roots) and _lexically_under_any(path, unc_roots)
def denied_message(path: str) -> str:
browse = "click the Browse button on the node and pick this folder once — that approves it permanently."
manual = (
"add it to \"folders\" (or set \"allow_any\": true) in\n {config}"
).format(config=_config_path())
if dialog_available():
how = f"To approve it, {browse}\nNo folder dialog available (headless install)? Instead {manual}"
else:
how = f"No folder dialog on this machine (headless install), so approve it by hand: {manual}"
return (
f"[SmartBatchResize] Folder not approved: {path}\n"
f"{how}\n"
"ComfyUI's own input/output/temp folders always work without approval."
)