Mirrors the security architecture of ComfyUI_DropSendNode v1.1.x to close
the same vulnerability classes. The actual Google OAuth grant path is
unchanged from v1.0.1 (same Flow.from_client_config, same OOB redirect,
same fetch_token call), so existing users' OAuth setup continues to work.
What changed is the wrapper around it. Note: this release has not been
runtime-tested end to end; the security logic is a direct port of the
tested DropSend implementation.
Setup Node refactor:
- Removed google_client_id, google_client_secret, auth_code, and the
service-account JSON from INPUT_TYPES. They no longer enter
PromptServer.history, workflow JSON, PNG metadata, localStorage, or
copy-pasted nodes.
- Added a browser-only modal (web/drivesend_oauth.js) and a same-origin
POST route /drivesend/setup/stash for entry. Hardening: origin check,
live-WebSocket gate, per-IP rate limit, 32 KB body cap, JSON content
type lockdown, 60-second TTL, 32-entry capacity, lock-protected access.
- Added COMFYUI_DRIVESEND_ALLOW_SETUP=1 opt-in gate. Without it the node
refuses to write or clear credentials, blocking remote workflow
submitters from hijacking or wiping the operator's Drive connection.
- Removed plaintext stdout prints of GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
/ GOOGLE_REFRESH_TOKEN / COMFYUI_ENCRYPTION_KEY. Replaced with a
redacted (last-4-chars) banner.
- WebSocket credential delivery is sid-targeted, never broadcast. Refuses
to send when client_id is None and surfaces a clear error. JS handler
verifies client_id matches api.clientId before rendering.
- Service-account JSON is entered via a textarea in the same modal,
base64-encoded into GOOGLE_SERVICE_ACCOUNT_JSON for delivery.
File-system hardening:
- .env and token.json now created via os.open(O_WRONLY|O_CREAT|O_TRUNC,
0o600), race-free, instead of open('w') with default umask (0o644).
- Added safe_paths.py: clamps watch_folder to ComfyUI's output tree
(with COMFYUI_DRIVESEND_ALLOWED_WATCH_PATHS opt-in for additional
roots), rejects symlinks in event paths, validates Drive folder IDs.
- AutoUploader now runs every event path through safe_paths.is_safe_event_path.
Logging:
- Centralized logging in __init__.py with race-free 0o600 creation of
drivesend.log on the root logger. Removed module-level
logging.basicConfig duplicates from monitor_output.py, encrypt_file.py,
drivesend_uploader_node.py.
- Verified: no logger or print call in the codebase emits an access
token, refresh token, client secret, service-account JSON body, or
encryption key value.
.gitignore additions:
- token.json, client_secret.json, service_account.json (Google credential
caches, previously not ignored).
- Editor backups: *.bak, *.backup, *~, *.swp, *.swo, .idea/.
- Encrypted artifacts: *.enc.
- Stray credential material: *.key, *.pem, *.p12, *.pfx.
- .claude/, .pytest_cache/, .coverage, htmlcov/, build artifacts,
Thumbs.db, Desktop.ini.
README rewrite:
- Linear three-step structure (Google Cloud setup, Setup Node, AutoUploader).
- Cloud / Local / Service-Account sections for the Setup Node.
- Threat model section documenting all enforced protections.
- "Setup is disabled" and "Browser delivery refused" troubleshooting.
- No em dashes per project style.
Tests:
- tests/test_security.py with five regression tests covering: send_sync
is never called with sid=None for credential events; payload echoes
client_id for JS-side verification; .env file mode is exactly 0o600;
client_id pattern rejects malformed input; stash entries expire after
TTL. Tests run without google-auth, googleapiclient, or watchdog
installed via ghost modules.
Version: 1.0.1 -> 1.1.0
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
# safe_paths.py
|
|
#
|
|
# Path validation helpers used by DriveSend nodes to constrain user-supplied
|
|
# paths to safe locations.
|
|
#
|
|
# Threat model: a ComfyUI instance may be reachable over the network. Any
|
|
# workflow author can therefore set node inputs. Without restriction, the
|
|
# DriveSend uploader's watch_folder input becomes an arbitrary-file-read
|
|
# primitive. The watcher recursively uploads everything under the path to
|
|
# the operator's Google Drive account. We constrain watch_folder to the
|
|
# ComfyUI output directory tree, with an operator-controlled allowlist for
|
|
# advanced setups.
|
|
|
|
import os
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_output_root():
|
|
"""Return the absolute, real path of ComfyUI's output directory.
|
|
|
|
Falls back to <cwd>/output if folder_paths is unavailable (e.g. running
|
|
unit tests outside a ComfyUI process).
|
|
"""
|
|
try:
|
|
import folder_paths # type: ignore
|
|
root = folder_paths.get_output_directory()
|
|
except Exception:
|
|
root = os.path.join(os.getcwd(), "output")
|
|
return os.path.realpath(root)
|
|
|
|
|
|
def get_allowed_roots():
|
|
"""Return the list of directory roots a watch_folder is allowed to be in.
|
|
|
|
Always includes the ComfyUI output directory. Operators who need to
|
|
monitor additional locations (e.g. a custom output mount) can opt in by
|
|
setting COMFYUI_DRIVESEND_ALLOWED_WATCH_PATHS to an os.pathsep-separated
|
|
list of absolute paths. Workflow inputs cannot expand this list.
|
|
"""
|
|
roots = [get_output_root()]
|
|
extra = os.environ.get("COMFYUI_DRIVESEND_ALLOWED_WATCH_PATHS", "")
|
|
if extra:
|
|
for raw in extra.split(os.pathsep):
|
|
raw = raw.strip()
|
|
if raw:
|
|
roots.append(os.path.realpath(raw))
|
|
return roots
|
|
|
|
|
|
def _is_within(child, parent):
|
|
"""True iff `child` is `parent` or a descendant of it (after realpath)."""
|
|
try:
|
|
return os.path.commonpath([child, parent]) == parent
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def resolve_safe_watch_folder(user_input):
|
|
"""Resolve a user-supplied watch_folder to a safe absolute path.
|
|
|
|
- Empty input -> the ComfyUI output directory.
|
|
- Relative input -> joined under the output directory.
|
|
- Absolute input -> must resolve under one of the allowed roots.
|
|
|
|
Raises ValueError with a clear message if the input escapes the allowed
|
|
set (path traversal, symlinks pointing outside, unrelated absolute path).
|
|
"""
|
|
output_root = get_output_root()
|
|
allowed_roots = get_allowed_roots()
|
|
|
|
if user_input is None or str(user_input).strip() == "":
|
|
return output_root
|
|
|
|
candidate = str(user_input).strip()
|
|
|
|
if not os.path.isabs(candidate):
|
|
candidate = os.path.join(output_root, candidate)
|
|
|
|
resolved = os.path.realpath(candidate)
|
|
|
|
for root in allowed_roots:
|
|
if _is_within(resolved, root):
|
|
return resolved
|
|
|
|
raise ValueError(
|
|
"watch_folder must be inside the ComfyUI output directory "
|
|
f"({output_root}). Got: {user_input!r} (resolved: {resolved}). "
|
|
"If you need to monitor a different location, set the "
|
|
"COMFYUI_DRIVESEND_ALLOWED_WATCH_PATHS environment variable on the "
|
|
"host before starting ComfyUI."
|
|
)
|
|
|
|
|
|
def is_safe_event_path(path):
|
|
"""True iff a Watchdog file event path is safe to process.
|
|
|
|
Rejects (a) symlinks and (b) paths whose realpath escapes the allowed
|
|
roots. Defends against symlinks placed inside the watched directory
|
|
that point at sensitive files outside it.
|
|
"""
|
|
try:
|
|
if os.path.islink(path):
|
|
return False
|
|
resolved = os.path.realpath(path)
|
|
except OSError:
|
|
return False
|
|
|
|
for root in get_allowed_roots():
|
|
if _is_within(resolved, root):
|
|
return True
|
|
return False
|
|
|
|
|
|
def validate_drive_folder_id(folder_id):
|
|
"""Validate a Google Drive folder ID string.
|
|
|
|
Drive folder IDs are URL-safe base64 strings, typically 28-44 chars.
|
|
We reject empty values, NUL bytes, and obvious format violations.
|
|
Defense in depth. Drive itself rejects malformed IDs server-side.
|
|
"""
|
|
if folder_id is None or str(folder_id).strip() == "":
|
|
raise ValueError("folder_id must not be empty.")
|
|
|
|
value = str(folder_id).strip()
|
|
|
|
if "\x00" in value:
|
|
raise ValueError("folder_id contains a NUL byte.")
|
|
if "/" in value or "\\" in value:
|
|
raise ValueError(
|
|
f"folder_id must not contain path separators. "
|
|
f"Provide just the ID after /folders/ in your Drive URL. "
|
|
f"Got: {value!r}"
|
|
)
|
|
if len(value) > 128:
|
|
raise ValueError(f"folder_id is suspiciously long ({len(value)} chars).")
|
|
|
|
return value
|