Compare commits

...
5 Commits
14 changed files with 432 additions and 105 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
# Optional Civitai API Key for auto-downloading LoRA preview thumbnails
CIVITAI_API_KEY=
# Optional TMDB API Key / Read Access Token for celebrity image preview search
# Optional TMDB API Key / Read Access Token for actor / media preview image search
TMDB_API_KEY=
# Feature toggles
+2 -2
View File
@@ -129,8 +129,8 @@ When implementing a new custom node, you **must complete all 5 steps**:
- Previews & Decisions: Violet (`color: "#7c3aed"`, `bgcolor: "#6d28d9"`)
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
- [ ] Add a collapsible `<details>` section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
- [ ] Add a clean heading section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
- Heading: `### 🪐 🏷️ Display Name — \`ClassName\``
- `#### Overview`
- `#### Inputs & Widgets`
- `#### Outputs`
+23 -1
View File
@@ -5,6 +5,28 @@ All notable changes to `ComfyUI-SaturnNodes` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.1] - Latest (Development)
### Changed
- Active development track for subsequent patches and visual LoRA loader V1 enhancements.
---
## [2.4.0] - 2026-09-24 [Stable]
### Fixed
- **Frontend V1 Sizing & Layout Stability**:
- Eliminated rapid runaway downward expansion loop in `VisualLoraLoader` and `ImageLoader` on LiteGraph classic (Frontend V1).
- Cleaned duplicate function stubs and unified `computeSize()` and `computeLayoutSize()`.
- **Core Stability & Logic**:
- Implemented true cycle permutation for `Random (Cycle)` in `PromptQueueIterator`.
- Upgraded comma parsing in `MultiTextReplacer` with `csv.reader` to preserve quoted phrases.
- Removed legacy node mappings (`LeafFlowDecision`, `LeafFlowTextSplit`).
- Added robust exception handling for unterminated regex in text splitting.
- Optimized 60fps canvas draw loops by decoupling badge geometry enforcement from `onDrawForeground`.
---
## [2.3.2] - 2026-09-22
### Changed & Rebranded
@@ -175,7 +197,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Visual LoRA Loader**: Folder-filtered LoRA selector with formatted pretty names, usage tracking, and Civitai SHA256 API preview thumbnail downloading.
- **Visual Image Loader**: Folder image picker with PNG parameters & EXIF user comments metadata parsing.
- **Auto Watcher**: Folder watcher with non-blocking check (`wait_for_image = False`) and blocking poll (`wait_for_image = True`).
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/celebrity names.
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/subject names.
- **Recent Outputs**: Output image loader by recent step index for assets/sidebar integration.
- **Live Latent Preview**: Real-time sampler latent preview canvas node renderer.
- **Settings Panel**: Native ComfyUI Settings menu controls for API keys and UI toggles.
+10 -7
View File
@@ -1,6 +1,6 @@
# 🪐 ComfyUI-SaturnNodes
A unified workflow control and productivity suite for **ComfyUI**. Built for **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** (fully supported and actively tested) with backwards compatibility for the **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)** (supported as-is, but untested—no guarantees).
A unified workflow control and productivity suite for **ComfyUI**. Both **Frontend V1 (LiteGraph)** and **Frontend V2 (Vue UI)** are supported, though `VisualLoraLoader` breaks on V1 for now (will be fixed).
---
@@ -28,7 +28,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder filter (e.g. `Celebrities`, `PonyV6`, `*`).
- **`folder`** (`STRING`): Subfolder filter (e.g. `Characters`, `Styles/Anime`, `*`).
- **`lora_name`** (`COMBO`): Dropdown listing available LoRA files in the target folder.
- **`strength_model`** (`FLOAT`): Model weight strength (default `1.0`).
- **`strength_clip`** (`FLOAT`): CLIP weight strength (default `1.0`).
@@ -45,7 +45,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
<summary><b>🪐 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
#### Overview
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
Loads a LoRA using formatted pretty names (e.g. `Cyberpunk Character V1` instead of `char_Cyberpunk-Character_v1.safetensors`).
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
@@ -81,6 +81,9 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`lora_name`** (`STRING`): Comma-separated list of active LoRA names.
> [!NOTE]
> Frontend V1 and V2 are supported, though `VisualLoraLoader` breaks on Frontend V1 for now (will be fixed). Frontend V2 (Vue UI) is recommended for visual LoRA browsing.
</details>
<details>
@@ -106,7 +109,7 @@ Visual thumbnail browser for image folders with instant preview selection, indep
<summary><b>🪐 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
#### Overview
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading. Path access and deletion are strictly confined to ComfyUI `input` and `output` directories.
#### Inputs & Widgets
- **`folder`** (`STRING`): Watch directory path (default `input/watch`).
@@ -114,7 +117,7 @@ Monitors a folder for incoming images, loads the target image into a PyTorch ten
- **`rescan_interval`** (`INT`): Polling frequency in seconds.
- **`sort_by`** (`COMBO`): Sort by `date_modified`, `date_created`, or `name`.
- **`regex_filter`** (`STRING`): Regex pattern to filter filenames.
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading.
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading (strictly inside `input` or `output`).
#### Outputs
- **`image`** (`IMAGE`): Loaded image tensor.
@@ -333,8 +336,8 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **`Custom Node Body Color`** (`color`, *Default: `#472a15`*): Body background color picker for SaturnNodes when custom colors are enabled.
- **`Civitai API Key`** (`text`): Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
- **`Enable Civitai Auto-Scraping`** (`boolean`, *Default: true*): Automatically download model preview images from Civitai via SHA256 file hashes. (Note: Local SHA256 hash searching always works).
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for celebrity preview search.
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download celebrity preview images from TMDB.
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for actor / media preview search.
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download media preview images from TMDB.
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
</details>
+169
View File
@@ -0,0 +1,169 @@
# 🛡️ ComfyUI Node Security Audit Playbook
*A Repeatable, Step-by-Step Security Review & Conformance Framework for ComfyUI Custom Node Developers and Auditors.*
---
## 🎯 The Core Philosophy & Threat Model
> **The Golden Rule (by @ltdrdata, ComfyUI-Manager maintainer):**
> *"Treat every input, widget values included, as attacker-controlled, because it can come from a shared workflow or a direct `/prompt` call."*
### Why ComfyUI Nodes Require a Hostile Threat Model
1. **Shared Workflow Vectors:** ComfyUI workflows are routinely shared across the internet as `.json` files or embedded inside `.png` image metadata (on Civitai, Reddit, Discord, GitHub).
2. **Zero-Interaction Poisoning:** When a user drags and drops a contaminated image or loads a workflow JSON, widget values are immediately populated with whatever data the author specified.
3. **Headless & API Execution:** Malicious payloads can be sent directly to the local server via `POST /prompt` without any UI interaction.
4. **Local Host Privileges:** ComfyUI runs with the full local privileges of the host user account. Any vulnerability can lead to Remote Code Execution (RCE), arbitrary file deletion, or sensitive data theft.
---
## 📋 The 4-Phase Security Audit Checklist
When auditing a new or existing custom node pack, execute the following four phases step by step.
---
### Phase 1: Node-by-Node Audit Matrix (The 7 Vector Checklist)
Repeat this checklist for **every single custom node** in `NODE_CLASS_MAPPINGS`.
#### 1.1 Filesystem Paths & Directory Traversal
* [ ] **No Unchecked Absolute Paths:** Does the node accept file or folder paths as input strings?
- Absolute paths (e.g. `C:\`, `/etc/`, `\\server\share`) must be strictly rejected unless explicitly intended and approved.
- Windows drive letter patterns (`^[A-Za-z]:`) and UNC paths (`^//`, `^\\\\`) must be blocked.
* [ ] **No Directory Traversal (`..`):** Does the node reject any path segment containing `..`?
* [ ] **Canonical Path Confinement via `commonpath`:**
- Are paths resolved to canonical real paths using `os.path.realpath`?
- Are resolved paths checked against an authorized base directory using `os.path.commonpath`?
```python
real_base = os.path.realpath(allowed_base)
real_target = os.path.realpath(target_path)
if os.path.commonpath([real_base, real_target]) != real_base:
raise ValueError("Directory traversal attempt detected.")
```
* [ ] **Confined Base Directories:** Are file reads and writes confined strictly to approved ComfyUI folders (`input/`, `output/`, `temp/`, or `models/`)?
#### 1.2 Destructive Actions (Deletions, Writes, Overwrites)
* [ ] **Safe Deletion Guards:** If the node contains `os.remove`, `os.unlink`, or `shutil.rmtree`:
- Is file deletion strictly confined to `input/` or `output/`?
- Does the node refuse to delete files outside the confined sandbox, even if a manipulated path is provided?
- Does the deletion toggle default to `False`?
* [ ] **Atomic File Writes:** When writing JSON, cache, or state files, are writes executed atomically via temporary files (`.tmp` + `os.replace`) to prevent corruption during unexpected shutdowns?
* [ ] **Read-Only Filesystem Defense:** Are disk writes wrapped in `try / except (PermissionError, OSError)` with graceful logging to prevent server crashes on read-only Docker/network mounts?
#### 1.3 Command & Process Execution
* [ ] **Never Use `shell=True`:** Are all subprocess calls executed with `shell=False` and tokenized argument lists (`shlex.split` or list of strings)?
* [ ] **Strict Executable Confinement:** Are scripts or binaries restricted strictly to a dedicated directory (e.g., `ComfyUI/scripts/`)?
* [ ] **In-Memory Operator Authorization Gate:**
- Can script execution consent travel inside workflow JSON or image metadata? (Answer must be **NO**).
- Does execution require an interactive, in-memory user action on the canvas node (e.g. `[⚡ Authorize Run]` with an ephemeral RAM token) before queueing?
* [ ] **Master Safety Switch:** Is local script/process execution disabled by default in settings (`ENABLE_LOCAL_FILE_EXECUTION = False`)?
* [ ] **Process Tree Termination & Timeouts:** Do subprocesses enforce strict execution timeouts and clean process-tree termination on user cancellation?
#### 1.4 Dynamic Code Evaluation
* [ ] **Zero `eval()` or `exec()` on Inputs:** Does the node evaluate mathematical expressions, conditions, or strings using Python's `eval()` or `exec()`?
- `eval()` and `exec()` on widget inputs represent instantaneous Remote Code Execution (RCE).
- Use safe AST parsers (such as `ast.literal_eval` or custom math parsers) instead.
* [ ] **No Dynamic Imports from Input Strings:** Does the node invoke `__import__` or `importlib.import_module` using user-supplied module names?
#### 1.5 Outbound Network Requests & SSRF
* [ ] **Strict Protocol Validation:** Do web requests (e.g. preview scrapers, API fetchers) strictly require `https://` or `http://`? (`file://`, `ftp://`, `gopher://` must be rejected).
* [ ] **Internal Network & Loopback Blocking:**
- Are requests to `127.0.0.1`, `localhost`, `::1`, and `0.0.0.0` blocked?
- Are private IPv4/IPv6 ranges blocked (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`)?
- Are cloud metadata services blocked (`169.254.169.254` on AWS/GCP/Azure)?
* [ ] **Opt-In Network Activity:** Are external web scraping or API features disabled by default, requiring explicit user activation?
#### 1.6 Regular Expression DoS (ReDoS) & Input Validation
* [ ] **Regex Error Handling:** Are user-provided regular expressions wrapped in `try / except re.error` with a safe fallback to prevent unhandled crashes?
* [ ] **Catastrophic Backtracking Defense:** Are regex patterns inspected for exponential backtracking vulnerability (e.g. `(a+)+$`)?
* [ ] **String Splitting Delimiters:** When parsing delimited inputs (e.g. CSV find/replace targets), does the parser handle quoted strings containing delimiters without fragmentation?
#### 1.7 Resource Exhaustion & Memory Allocation
* [ ] **Bounded Iteration:** Do loop nodes (e.g. batch iterators, queue steppers) enforce sane upper bounds to prevent infinite loops?
* [ ] **Image Dimension Clamping:** Are image dimensions, aspect ratio scalers, and tensor buffers validated to prevent out-of-memory (OOM) allocation bombs?
---
### Phase 2: Administrative Endpoints & Web Routes Review
Examine all routes registered via `PromptServer.instance.app.routes` in `__init__.py` or route handlers.
#### 2.1 Loopback Origin Enforcement (`is_local_request`)
* [ ] **Local-Only Verification:** Do sensitive endpoints check the client's peer IP address and reject non-loopback connections (`127.0.0.1` / `::1`) with `403 Forbidden`?
#### 2.2 Cross-Site Request Forgery (CSRF) & Fetch Metadata
* [ ] **Same-Machine Browser Exploit Defense:** A local-only IP check is insufficient on its own because any malicious website visited by the user in another browser tab makes requests from `localhost`!
* [ ] **`Sec-Fetch-Site` Validation:** Does the route inspect the browser-enforced `Sec-Fetch-Site` header and reject `cross-site` calls?
* [ ] **Origin / Referer Validation:** Does the route verify that `Origin` or `Referer` matches the ComfyUI host?
* [ ] **Cryptographic Session CSRF Token:** Do state-mutating `POST` endpoints require a session token header (e.g. `X-CSRF-Token`) generated on server startup?
#### 2.3 Two-Step Ephemeral Ticket Handshakes for High-Impact Actions
* [ ] **Destructive Action Isolation:** Are high-impact administrative actions (server restart, shutdown, mass cache wipe) separated into a two-step handshake?
1. `POST /request_token`: Client requests a single-use token stored in server RAM with a short TTL (30 seconds).
2. `POST /confirm_action`: Client presents the single-use token to execute the action.
* [ ] **Default Disabled:** Are server management features disabled by default in settings (`ALLOW_PROCESS_MANAGEMENT = False`)?
---
### Phase 3: Settings & Persistent Configuration Review
Inspect how preferences, credentials, and states are stored.
#### 3.1 Safe Directory Isolation
* [ ] **Centralized Data Directory:** Are user data and settings stored in `ComfyUI/user/default/<PackName>/` rather than cluttering root or writing to random system paths?
* [ ] **No Secret Tracking in Git:** Are `.env` files, API keys, and local state files strictly added to `.gitignore`?
* [ ] **Log Masking:** Are secret keys (e.g. Civitai API keys, TMDB tokens) masked (`***` or truncated) when output to server logs?
#### 3.2 Configuration File Injection Defense
* [ ] **Newline Injection Defense:** When writing `.env` or configuration key-value pairs, are keys and values sanitized against `\r` and `\n` to prevent config poisoning?
---
### Phase 4: ComfyUI Ecosystem & Registry Compliance
Review package structure against the official Comfy-Org registry and ComfyUI-Manager requirements.
#### 4.1 Packaging & Dependency Standards
* [ ] **No `install.py`:** Is `install.py` completely eliminated? (Comfy-Org prohibits `install.py` executing `pip install` subprocesses on startup).
* [ ] **Transparent `requirements.txt` & `pyproject.toml`:** Are dependencies clearly declared with standard version constraints?
* [ ] **No PyTorch in Dependencies:** Is `"torch"` omitted from `requirements.txt` / `pyproject.toml` dependencies? (ComfyUI manages its own PyTorch/CUDA environment).
* [ ] **Optional Headless Dependencies:** Are GUI-dependent packages (like system tray `pystray`) made optional or headless-tolerant so cloud containers (Docker, RunPod, Colab) boot cleanly?
#### 4.2 Frontend Compatibility & Widget Serialization
* [ ] **Dual Frontend Support:** Does the node suite function cleanly on both **Frontend V1 (LiteGraph)** and **Frontend V2 (Vue UI)**?
* [ ] **No Canvas Runaway Loops:** Do custom DOM widgets avoid overriding `computeSize()` with formulas tied to `node.size[1]`, which triggers infinite downward expansion in LiteGraph?
* [ ] **Serialization Hygiene:** Are UI-only preview badges and widgets explicitly marked `{ serialize: false }` to avoid polluting saved workflow JSON?
#### 4.3 Core Monkey-Patching Isolation
* [ ] **Conditional Patching:** If the node pack overrides any core ComfyUI server or queue functions (e.g. `PromptQueue`), is the patch completely bypassed when the feature toggle is disabled?
---
## 🔍 Rapid Audit Checklist Matrix
Use this quick scorecard when reviewing a custom node pack:
| Category | Check | Status |
| :--- | :--- | :---: |
| **Inputs** | Absolute paths rejected | [ ] |
| **Inputs** | Directory traversal (`..`) rejected | [ ] |
| **Inputs** | Path confinement verified with `commonpath` | [ ] |
| **Inputs** | Regex errors caught and handled | [ ] |
| **Execution** | `shell=False` enforced on all subprocesses | [ ] |
| **Execution** | Scripts confined to dedicated directory | [ ] |
| **Execution** | Consent cannot travel in workflow JSON | [ ] |
| **Execution** | In-memory interactive operator authorization | [ ] |
| **Network** | SSRF protection: loopback & private LAN blocked | [ ] |
| **Network** | Protocol restricted to HTTP/HTTPS | [ ] |
| **Network** | Scrapers & network features opt-in | [ ] |
| **Endpoints** | Loopback origin checks on admin routes | [ ] |
| **Endpoints** | `Sec-Fetch-Site` CSRF defense | [ ] |
| **Endpoints** | Ephemeral ticket handshake for power actions | [ ] |
| **Settings** | Dangerous features default to `False` | [ ] |
| **Settings** | Data stored in `user/default/<pack>/` | [ ] |
| **Packaging** | No `install.py` / import-time `pip` | [ ] |
| **Packaging** | Dependencies in `requirements.txt` | [ ] |
| **Frontend** | Clean V1 and V2 compatibility without resize loops | [ ] |
---
*Created for ComfyUI-SaturnNodes to maintain institutional security compliance.*
+1 -1
View File
@@ -4,7 +4,7 @@ import subprocess
from aiohttp import web
from server import PromptServer
__version__ = "2.3.2"
__version__ = "2.4.1"
from .nodes.queue_control import setup_queue_control_routes, tray_manager
from .nodes.lora_loader import (
+5 -2
View File
@@ -5,7 +5,7 @@ import numpy as np
import re
from PIL import Image, ImageOps
import comfy.model_management
from .utils import sanitize_folder_path
from .utils import sanitize_folder_path, is_safe_path, get_allowed_folder_watch_directories
def get_current_prompt_id():
try:
@@ -70,6 +70,9 @@ class LoadImageFromFolder:
img_tensor = torch.from_numpy(image_array)[None,]
if delete_image:
if not is_safe_path(filepath, allowed_bases=get_allowed_folder_watch_directories()):
print(f"[SaturnNodes Security] Refusing to delete '{filepath}': file is outside ComfyUI input/output directories.")
return img_tensor
try:
os.remove(filepath)
except Exception as e:
@@ -148,7 +151,7 @@ class LoadImageFromFolder:
return files[idx]
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, unique_id="default", **kwargs):
folder = sanitize_folder_path(folder, default_dir="input/watch")
folder = sanitize_folder_path(folder, default_dir="watch")
if wait_if_folder_is_empty:
while True:
+131 -55
View File
@@ -180,26 +180,43 @@ def is_safe_path(target_path, allowed_bases=None):
def sanitize_image_loader_folder(folder_input, default_to_output=True):
"""
Resolves folder input strictly within ComfyUI input/output/temp directories.
If folder_input attempts to escape or points outside allowed bases, falls back safely.
Rejects directory traversal ('..') and unapproved absolute paths, strictly verifying confinement via commonpath.
"""
allowed_bases = get_allowed_image_directories()
input_dir = folder_paths.get_input_directory()
output_dir = folder_paths.get_output_directory()
default_dir = output_dir if default_to_output else input_dir
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
output_dir = folder_paths.get_output_directory() if hasattr(folder_paths, "get_output_directory") else "."
default_dir = os.path.abspath(os.path.realpath(output_dir if default_to_output else input_dir))
if not folder_input or not str(folder_input).strip():
return default_dir
clean = str(folder_input).strip().rstrip("\\/")
# 1. Reject directory traversal sequences ('..')
norm_slashes = clean.replace("\\", "/")
if any(p == ".." for p in norm_slashes.split("/")):
print(f"[SaturnNodes Security] Blocked directory traversal in image folder path '{clean}'. Confining to ComfyUI directories.")
return default_dir
# 2. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
is_absolute = (
os.path.isabs(clean)
or bool(re.match(r'^[A-Za-z]:', clean))
or clean.startswith(("//", "\\\\"))
)
if is_absolute:
if not is_safe_path(clean, allowed_bases):
print(f"[SaturnNodes Security] Blocked absolute image folder path '{clean}'. Confining to ComfyUI directories.")
return default_dir
return os.path.abspath(os.path.realpath(clean))
# 3. Handle relative 'input' or 'output' prefixes
if clean.lower() == "input" or clean.lower().startswith("input/") or clean.lower().startswith("input\\"):
sub = clean[5:].lstrip("\\/")
resolved = os.path.join(input_dir, sub) if sub else input_dir
elif clean.lower() == "output" or clean.lower().startswith("output/") or clean.lower().startswith("output\\"):
sub = clean[6:].lstrip("\\/")
resolved = os.path.join(output_dir, sub) if sub else output_dir
elif os.path.isabs(clean):
resolved = clean
else:
cand_out = os.path.join(output_dir, clean)
cand_inp = os.path.join(input_dir, clean)
@@ -209,7 +226,7 @@ def sanitize_image_loader_folder(folder_input, default_to_output=True):
resolved = cand_out
if is_safe_path(resolved, allowed_bases):
return os.path.normpath(resolved)
return os.path.abspath(os.path.realpath(resolved))
return default_dir
@@ -262,74 +279,133 @@ def parse_pretty_name_with_version(filepath):
return f"{pretty_name} {version_candidate.upper()}"
return pretty_name
def get_allowed_folder_watch_directories():
"""
Returns approved base directories for folder watching and recent output scanning:
strictly ComfyUI input and output directories.
"""
allowed = []
try:
inp = folder_paths.get_input_directory()
if inp:
allowed.append(os.path.abspath(os.path.realpath(inp)))
except Exception:
pass
try:
out = folder_paths.get_output_directory()
if out:
allowed.append(os.path.abspath(os.path.realpath(out)))
except Exception:
pass
return allowed
def sanitize_folder_path(folder_input, default_dir=None):
"""
Confines folder paths strictly within ComfyUI's input and output directories.
Rejects absolute paths and '..' directory traversal sequences.
Resolves canonical realpath and verifies commonpath containment.
"""
allowed_bases = get_allowed_folder_watch_directories()
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else None
safe_fallback = os.path.join(input_dir, "watch") if input_dir else "watch"
if default_dir:
if os.path.isabs(default_dir):
try:
real_def = os.path.abspath(os.path.realpath(default_dir))
for base in allowed_bases:
if os.path.commonpath([base, real_def]) == base:
safe_fallback = real_def
break
except Exception:
pass
else:
safe_fallback = default_dir
if folder_input is None:
folder_input = ""
clean_folder = str(folder_input).strip()
# Strip any trailing wildcards (e.g. "krea2\*" -> "krea2", "krea2/*" -> "krea2")
# Strip any trailing wildcards (e.g. "subfolder\*" -> "subfolder", "subfolder/*" -> "subfolder")
clean_folder = re.sub(r'[\*\?]+$', '', clean_folder).rstrip("\\/")
if not clean_folder and default_dir:
clean_folder = default_dir
if not clean_folder:
return ""
clean_folder = safe_fallback
# 1. If absolute path, verify directly with case-insensitive check
# 1. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
is_absolute = (
os.path.isabs(clean_folder)
or bool(re.match(r'^[A-Za-z]:', clean_folder))
or clean_folder.startswith(("//", "\\\\"))
)
# 2. Reject directory traversal sequences ('..')
normalized_slashes = clean_folder.replace("\\", "/")
parts = normalized_slashes.split("/")
has_traversal = any(p == ".." for p in parts)
if is_absolute or has_traversal:
is_safe_default = False
if is_absolute and safe_fallback and clean_folder == safe_fallback:
try:
real_clean = os.path.abspath(os.path.realpath(clean_folder))
for base in allowed_bases:
if os.path.commonpath([base, real_clean]) == base:
is_safe_default = True
break
except Exception:
pass
if not is_safe_default:
print(f"[SaturnNodes Security] Blocked unsafe folder path '{clean_folder}' (absolute={is_absolute}, traversal={has_traversal}). Confining to ComfyUI input/output directories.")
clean_folder = safe_fallback
# 3. If clean_folder is an already-confined absolute path (e.g. from internal safe_fallback)
if os.path.isabs(clean_folder):
norm_path = os.path.normpath(clean_folder)
if os.path.exists(norm_path):
return norm_path
parent_dir = os.path.dirname(norm_path)
base_name = os.path.basename(norm_path)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
except Exception:
pass
return norm_path
try:
real_cand = os.path.abspath(os.path.realpath(clean_folder))
for base in allowed_bases:
if os.path.commonpath([base, real_cand]) == base:
return real_cand
except Exception:
pass
return os.path.abspath(os.path.realpath(safe_fallback))
# 2. If relative path, check output directory, input directory, then base path
# 4. For relative paths, evaluate candidates strictly within allowed_bases
candidates = []
try:
output_dir = folder_paths.get_output_directory()
if output_dir:
candidates.append(os.path.normpath(os.path.join(output_dir, clean_folder)))
except Exception:
pass
for base in allowed_bases:
target = os.path.abspath(os.path.realpath(os.path.join(base, clean_folder)))
try:
if os.path.commonpath([base, target]) == base:
candidates.append((target, os.path.exists(target)))
except Exception:
continue
try:
input_dir = folder_paths.get_input_directory()
if input_dir:
candidates.append(os.path.normpath(os.path.join(input_dir, clean_folder)))
except Exception:
pass
# Return existing candidate if present on disk
for target, exists in candidates:
if exists:
return target
try:
if folder_paths.base_path:
candidates.append(os.path.normpath(os.path.join(folder_paths.base_path, clean_folder)))
except Exception:
pass
for cand in candidates:
if os.path.exists(cand):
return cand
for cand in candidates:
parent_dir = os.path.dirname(cand)
base_name = os.path.basename(cand)
# Case-insensitive resolution within candidate parent folders
for target, _ in candidates:
parent_dir = os.path.dirname(target)
base_name = os.path.basename(target)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
match_path = os.path.join(parent_dir, entry)
real_match = os.path.abspath(os.path.realpath(match_path))
for base in allowed_bases:
if os.path.commonpath([base, real_match]) == base:
return real_match
except Exception:
pass
return candidates[0] if candidates else os.path.normpath(os.path.join(folder_paths.base_path, clean_folder))
if candidates:
return candidates[0][0]
default_input = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
return os.path.abspath(os.path.realpath(os.path.join(default_input, "watch")))
def format_lora_output_name(resolved_path, display_name, output_format="Parsed Name", custom_regex=""):
if not resolved_path or resolved_path in ["[ NONE ]", "[ RANDOM ]"]:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-saturnnodes"
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
version = "2.3.2"
version = "2.4.1"
readme = "README.md"
license = "MIT"
dependencies = ["pillow", "numpy", "piexif"]
+10 -5
View File
@@ -12,8 +12,12 @@ from nodes.auto_watcher import LoadImageFromFolder
class TestAutoWatcher(unittest.TestCase):
def setUp(self):
import folder_paths
self.node = LoadImageFromFolder()
self.temp_dir = tempfile.mkdtemp()
inp_dir = folder_paths.get_input_directory()
os.makedirs(inp_dir, exist_ok=True)
self.temp_dir = tempfile.mkdtemp(dir=inp_dir)
self.rel_dir = os.path.relpath(self.temp_dir, inp_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
@@ -44,8 +48,9 @@ class TestAutoWatcher(unittest.TestCase):
def test_empty_folder_returns_dummy(self):
empty_dir = os.path.join(self.temp_dir, "empty")
os.makedirs(empty_dir, exist_ok=True)
empty_rel = os.path.join(self.rel_dir, "empty")
img_tensor, has_image = self.node.watch(
folder=empty_dir,
folder=empty_rel,
wait_if_folder_is_empty=False,
rescan_interval=1,
sort_by="date_modified",
@@ -62,15 +67,15 @@ class TestAutoWatcher(unittest.TestCase):
Image.new("RGB", (64, 64), color="green").save(f2)
# Run 1 -> loads img_a
t1, has1 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t1, has1 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has1)
# Run 2 -> loads img_b (cycled!)
t2, has2 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t2, has2 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has2)
# Run 3 -> loops back to img_a
t3, has3 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t3, has3 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has3)
def test_load_and_remove_image_with_delete_toggle(self):
+11 -9
View File
@@ -13,8 +13,12 @@ from nodes.load_recent import LoadRecentOutputs
class TestLoadRecent(unittest.TestCase):
def setUp(self):
import folder_paths
self.node = LoadRecentOutputs()
self.temp_dir = tempfile.mkdtemp()
out_dir = folder_paths.get_output_directory()
os.makedirs(out_dir, exist_ok=True)
self.temp_dir = tempfile.mkdtemp(dir=out_dir)
self.rel_dir = os.path.relpath(self.temp_dir, out_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
@@ -26,16 +30,14 @@ class TestLoadRecent(unittest.TestCase):
img.save(f1)
img.save(f2)
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=2, index=0)
self.assertEqual(out_tensor.shape[0], 1)
self.assertEqual(out_tensor.shape[1], 64)
self.assertEqual(out_tensor.shape[2], 64)
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=2, index=0)
self.assertEqual(out_tensor.shape[0], 1)
self.assertEqual(out_tensor.shape[1], 64)
self.assertEqual(out_tensor.shape[2], 64)
def test_load_recent_empty_directory_returns_dummy(self):
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=5, index=0)
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=5, index=0)
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -36,9 +36,9 @@ class TestLoraFinder(unittest.TestCase):
@patch("nodes.lora_finder.comfy.sd.load_lora_for_models")
def test_find_and_load_lora_match(self, mock_load_lora, mock_load_torch, mock_exists, mock_get_path, mock_mapping):
mock_mapping.return_value = {
"Ana De Armas": "celebrities/krea2_ana-de-armas_v1.safetensors"
"Cyberpunk Character": "characters/char_cyberpunk-character_v1.safetensors"
}
mock_get_path.return_value = "/models/loras/celebrities/krea2_ana-de-armas_v1.safetensors"
mock_get_path.return_value = "/models/loras/characters/char_cyberpunk-character_v1.safetensors"
mock_exists.return_value = True
mock_load_torch.return_value = {}
mock_load_lora.return_value = ("PATCHED_MODEL", "PATCHED_CLIP")
@@ -46,13 +46,13 @@ class TestLoraFinder(unittest.TestCase):
model, clip, loras = self.finder.find_and_load_loras(
model="MODEL",
clip="CLIP",
text="A photo of Ana De Armas in evening dress",
text="A digital painting of a Cyberpunk Character at neon midnight",
search_for="Parsed Name",
output_format="Parsed Name"
)
self.assertEqual(model, "PATCHED_MODEL")
self.assertEqual(clip, "PATCHED_CLIP")
self.assertIn("Ana De Armas", loras)
self.assertIn("Cyberpunk Character", loras)
if __name__ == "__main__":
unittest.main()
+61 -14
View File
@@ -105,8 +105,8 @@ class TestUtils(unittest.TestCase):
shutil.rmtree(base1, ignore_errors=True)
def test_parse_pretty_name(self):
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name, "Ana De Armas")
name = parse_pretty_name("char_cyberpunk-character_v1.safetensors")
self.assertEqual(name, "Cyberpunk Character")
def test_parse_pretty_name_special_keywords(self):
name = parse_pretty_name("tag_cyberpunk-nsfw-v2.safetensors")
@@ -114,28 +114,63 @@ class TestUtils(unittest.TestCase):
self.assertIn("V2", name)
def test_parse_pretty_name_with_version(self):
name_v = parse_pretty_name_with_version("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name_v, "Ana De Armas V1")
name_v = parse_pretty_name_with_version("char_cyberpunk-character_v1.safetensors")
self.assertEqual(name_v, "Cyberpunk Character V1")
def test_format_lora_output_name_modes(self):
rel_path = os.path.join("celebrities", "ana_de_armas.safetensors")
rel_path = os.path.join("characters", "cyberpunk_character.safetensors")
parsed = format_lora_output_name(rel_path, "Ana De Armas", "Parsed Name")
self.assertEqual(parsed, "Ana De Armas")
parsed = format_lora_output_name(rel_path, "Cyberpunk Character", "Parsed Name")
self.assertEqual(parsed, "Cyberpunk Character")
filename = format_lora_output_name(rel_path, "Ana De Armas", "Filename")
self.assertEqual(filename, "ana_de_armas.safetensors")
filename = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename")
self.assertEqual(filename, "cyberpunk_character.safetensors")
no_ext = format_lora_output_name(rel_path, "Ana De Armas", "Filename without extension")
self.assertEqual(no_ext, "ana_de_armas")
no_ext = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename without extension")
self.assertEqual(no_ext, "cyberpunk_character")
custom_reg = format_lora_output_name(rel_path, "Ana De Armas", "Custom Regex", custom_regex=r"ana_([a-z]+)")
self.assertEqual(custom_reg, "ana_de")
custom_reg = format_lora_output_name(rel_path, "Cyberpunk Character", "Custom Regex", custom_regex=r"cyberpunk_([a-z]+)")
self.assertEqual(custom_reg, "cyberpunk_character")
def test_sanitize_folder_path_wildcard_stripping(self):
clean = sanitize_folder_path("input/watch/*")
clean = sanitize_folder_path("watch/*")
self.assertNotIn("*", clean)
def test_sanitize_folder_path_blocks_absolute_paths(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
# Test Windows drive letters and root paths
for bad_path in ["C:\\Windows\\System32", "D:\\SecretData", "/etc/passwd", "\\\\server\\share"]:
res = sanitize_folder_path(bad_path)
res_norm = os.path.abspath(os.path.realpath(res))
# Must be confined strictly within input or output directory
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Path '{bad_path}' escaped confinement! Got: '{res_norm}'")
def test_sanitize_folder_path_blocks_directory_traversal(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for bad_traversal in ["../../etc", "watch/../../secret", "..\\..\\Windows", "sub/../../../root"]:
res = sanitize_folder_path(bad_traversal)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Traversal '{bad_traversal}' escaped confinement! Got: '{res_norm}'")
def test_sanitize_folder_path_allows_safe_subfolders(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for safe_sub in ["watch", "my_images", "subfolder/nested"]:
res = sanitize_folder_path(safe_sub)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Safe subfolder '{safe_sub}' failed confinement! Got: '{res_norm}'")
def test_is_safe_external_image_url(self):
from nodes.lora_loader import is_safe_external_image_url
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
@@ -155,5 +190,17 @@ class TestUtils(unittest.TestCase):
self.assertEqual(parse_pretty_name("[ NONE ]"), "")
self.assertEqual(parse_pretty_name("[ RANDOM ]"), "")
def test_sanitize_image_loader_folder_confinement(self):
from nodes.utils import sanitize_image_loader_folder
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for bad in ["C:\\Windows", "/etc", "../../escape", "output/../../secret", "\\\\server\\share"]:
res = sanitize_image_loader_folder(bad)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Image folder '{bad}' escaped confinement: '{res_norm}'")
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -771,7 +771,7 @@ app.registerExtension({
name: "TMDB Access Token",
type: "text",
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.03_TMDBApiKey", ""),
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for actor and media preview image lookup. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
postSaturnNodesSettings({ tmdb_api_key: cleanKey });
@@ -784,7 +784,7 @@ app.registerExtension({
name: "Enable TMDB Auto-Scraping",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.04_EnableTMDBScraping", false),
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
tooltip: "Toggles automated downloading of media preview thumbnails from TMDB. Default is disabled.",
onChange(value) {
postSaturnNodesSettings({ enable_tmdb_scraping: value ? "true" : "false" });
}
@@ -1212,7 +1212,7 @@ function setupSettingsFooterObserver() {
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">🪐</span>
<span style="font-weight: 600; color: #e4e4e7;">ComfyUI-SaturnNodes</span>
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.3.2</span>
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.4.1 (latest)</span>
</div>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<a href="https://github.com/KOFiblto/ComfyUI-SaturnNodes" target="_blank" rel="noopener noreferrer" style="color: #decbb2; text-decoration: underline; text-decoration-color: rgba(222, 203, 178, 0.4); text-underline-offset: 3px; font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); transition: all 0.2s ease;" onmouseover="this.style.color='#ffffff'; this.style.background='rgba(255,255,255,0.12)'; this.style.borderColor='rgba(255,255,255,0.25)';" onmouseout="this.style.color='#decbb2'; this.style.background='rgba(255,255,255,0.05)'; this.style.borderColor='rgba(255,255,255,0.12)';">