Files
MachinePainting 09362f42c7 v1.1.0: refactor Setup Node, security hardening, auto-curl auth flow
Setup Node refactor (the secret-bearing fields no longer exist on the
node at all):
  • Removed app_key / app_secret / auth_code from INPUT_TYPES. The
    Setup Node now exposes only storage_method, encryption_key_method,
    and reconnect — none of which are credentials.
  • Added a "Set credentials..." button on the node (LiteGraph button
    widget installed via beforeRegisterNodeDef + onNodeCreated
    prototype hook so it survives reload, drag-and-drop workflow
    JSON, and PNG metadata restore).
  • Click opens a browser-only modal with three <input type=password>
    fields. Browser-native dot masking the whole time the user types
    or pastes — no canvas-rendered plaintext, no shoulder-surf.
  • Modal Save POSTs values to a new same-origin route
    /dropsend/setup/stash and closes. setup() consumes the stash
    entry on the next Queue (one-shot, keyed by client_id, 60-second
    TTL, 32-entry cap with oldest-eviction).
  • Architectural consequence: secrets never become widget values, so
    they cannot enter saved workflow JSON, ComfyUI's localStorage
    auto-save, PNG metadata, copy-pasted nodes, or PromptServer.history
    (and therefore the unauthenticated /history HTTP endpoint).

Auto-curl auth flow (one-click second paste):
  • After Run 1 prints the OAuth URL banner, setup() sends a
    sid-targeted dropsend_credentials_needed WebSocket event.
  • JS handler auto-opens the entry modal in the originating browser
    so the user can paste auth_code as soon as Dropbox shows it,
    without clicking the button between runs.
  • Defense in depth: client_id is echoed in the WS payload and the
    JS handler verifies it matches api.clientId before opening.

Security hardening on /dropsend/setup/stash:
  • Same-origin enforcement: Origin header must match request.host.
    Closes cross-origin CSRF from another browser tab even when an
    attacker has sniffed a victim's client_id.
  • Content-Type lockdown: rejects anything other than
    application/json with HTTP 415. Closes the confused-deputy class
    where form-encoded bodies could be parsed as JSON.
  • Live-session check: client_id must correspond to an active
    PromptServer.instance.sockets entry. Returns 403 otherwise.
    Closes the stash-poisoning vector where an attacker who guesses
    a victim's client_id could pre-populate credentials.
  • Per-IP rate limit: 30 POSTs / 10-second sliding window, returns
    429 over budget. Defense against stash-spam DoS attempting to
    evict a legitimate user's entry.
  • Body cap (32 KB), client_id regex validation, JSON parse errors
    return 400, empty payloads dropped.
  • Both rate-limit and live-session checks empirically verified via
    curl smoke tests.

Reconnect now revokes the Dropbox refresh token at the source:
  • reconnect=True populates DropboxAuthManager from os.environ and
    calls reset(revoke_token=True).
  • POST /2/auth/token/revoke with an 8-second timeout so a Dropbox
    outage cannot hang the operator mid-reconnect. Local cleanup
    proceeds regardless of revocation outcome.
  • Revocation outcome is now logged at logger.error level (not just
    print) and the failure message tells the operator to manually
    disconnect at https://www.dropbox.com/account/connected_apps so
    a silent revocation failure cannot leave a still-valid token at
    Dropbox.

Logging:
  • Single FileHandler attached to the root logger from __init__.py,
    pointed at a plugin-relative dropsend.log. Replaces the four
    duplicated logging.basicConfig() blocks in monitor_output.py /
    encrypt_file.py / dropbox_upload.py / dropsend_uploader_node.py
    that were dead code (basicConfig is a no-op once ComfyUI has
    already configured root). Log records from every DropSend module
    now actually persist to disk.
  • Race-free creation: pre-create with os.open(O_CREAT | O_WRONLY |
    O_APPEND, 0o600) before FileHandler attaches, follow-up chmod as
    belt-and-suspenders. Closes the brief default-umask window
    between FileHandler creating the file and post-hoc chmod.
  • Rotation via RotatingFileHandler (5 MB per file, 3 backups, ~20
    MB max). Bounds disk use on long-lived hosts.
  • dropbox_auth_manager.py now has its own logger so revocation
    successes/failures land in dropsend.log alongside stdout.

Tests (tests/ directory, unittest, no extra deps):
  • test_safe_paths.py — 23 tests covering symlink rejection, path-
    traversal rejection, allowlist behaviour, dropbox_dest validation
    (NUL bytes, missing leading slash, parent-traversal segments),
    and resolve_safe_watch_folder edge cases. safe_paths is the only
    barrier between remote workflow inputs and arbitrary-file-read
    on the host; this is regression coverage for that barrier.
  • test_setup_node_security.py — static AST analysis of
    dropsend_setup_node.py asserting that every send_sync call for a
    credential event passes a non-None sid AND sits inside an
    "if sid:" guard. Catches future regressions of the original
    WebSocket-broadcast vulnerability deterministically. Also asserts
    INPUT_TYPES never re-introduces app_key/app_secret/auth_code.

WebSocket-broadcast guard maintained on all three event sites
(dropsend_credentials_ready, dropbox_reconnect_complete,
dropsend_credentials_needed) — refuse to send when client_id is None
to prevent send_sync(..., None) from broadcasting credentials to
every connected browser tab.

README:
  • New Changelog section documenting the v1.1.0 security advisory
    (workflow-persistence + WS-broadcast leaks in earlier versions)
    and the fixes shipped in this release.
  • New "Bind ComfyUI to localhost or front it with auth" Best
    Practice item (item 4) explicitly calling out that ComfyUI's
    /prompt endpoint is unauthenticated.
  • New "Encryption key rotation" subsection covering symmetric-key
    implications, leaked-key recovery, and how to generate a new key.
  • New "Uninstalling / disconnecting" subsection with step-by-step
    instructions for revoking Dropbox app, resetting app secret,
    removing platform secrets, and handling existing .enc files.
  • Threat model reflects same-origin/Content-Type checks, log
    rotation, plugin-directory metadata-leak note for multi-tenant
    hosts, reconnect-revocation silent-failure note, and Dropbox
    SDK error-message log review.

.gitignore:
  • Expanded to cover *.bak, *.enc, *.key/.pem/.p12/.pfx, .idea/,
    .pytest_cache/, build artifacts, Thumbs.db/Desktop.ini.

Bundled in this minor release:
  • Single-Observer fix for the macOS _fsevents conflict that
    previously prevented .enc files from being picked up by the
    upload watcher.
  • folder_format dropdown for AutoUploader (project_name default
    plus five worldwide date formats), with two-base persistence so
    switching between project-name and dated modes preserves both
    patterns.
  • RUNNING banner with per-setting plain-English explanations.
  • Branded watching/encrypted/sent log emojis.
  • Per-shell copy-paste commands and echo verification for the
    COMFYUI_DROPSEND_ALLOW_SETUP gate.
2026-05-09 05:44:47 -07:00

319 lines
14 KiB
Python

# dropsend_uploader_node.py
import os
import threading
import logging
import time
from datetime import date, datetime
from dotenv import load_dotenv, dotenv_values
from watchdog.observers import Observer
from .monitor_output import start_monitoring, watcher_observer, stop_queue_processor
from .encrypt_file import FileEncryptHandler, ENCRYPT_EXTENSIONS, stop_queue_processor as stop_encrypt_queue_processor, get_encryption_key
from .safe_paths import resolve_safe_watch_folder, validate_dropbox_dest, get_output_root
# Folder format options. The first option uses the user's literal
# text as a project folder name (no date appended); the remaining
# options append today's date to the user's base folder in the
# selected style. Date formats use "-" or "." as separators rather
# than "/" so the date stays within a single path segment, which
# keeps _strip_trailing_date simple and avoids visually splitting
# the date across folders.
PROJECT_NAME_FORMAT = "project_name"
DATE_FORMATS = {
"mm-dd-yyyy": "%m-%d-%Y",
"dd-mm-yyyy": "%d-%m-%Y",
"yyyy-mm-dd": "%Y-%m-%d",
"dd.mm.yyyy": "%d.%m.%Y",
"yyyy.mm.dd": "%Y.%m.%d",
}
FOLDER_FORMAT_OPTIONS = [PROJECT_NAME_FORMAT] + list(DATE_FORMATS.keys())
DEFAULT_FOLDER_FORMAT = PROJECT_NAME_FORMAT
def _today_str(format_name):
fmt = DATE_FORMATS.get(format_name)
if not fmt:
return ""
return date.today().strftime(fmt)
def _strip_trailing_date(path, format_name):
"""If the last `/`-separated segment of `path` parses as a date in
the given format, return `path` with that segment removed. Otherwise
return `path` unchanged. Used to recover the BASE folder from a
persisted destination that already has a date appended."""
fmt = DATE_FORMATS.get(format_name)
if not fmt:
return path
cleaned = path.rstrip("/")
parts = cleaned.split("/")
if len(parts) < 2:
return path
try:
datetime.strptime(parts[-1], fmt)
except ValueError:
return path
return "/".join(parts[:-1]) or "/"
# Logging configured by the package __init__ — see __init__.py.
logger = logging.getLogger(__name__)
# Define encrypt_observer at module level
encrypt_observer = None
class DropSendAutoUploaderNode:
@classmethod
def INPUT_TYPES(cls):
node_dir = os.path.dirname(__file__)
env_path = os.path.join(node_dir, ".env")
default_watch = get_output_root()
default_dated_base = "/ComfyUI_Output_Files"
default_flat_base = "/ComfyUI-Project/My-Project"
default_encrypt = True
default_delete_enc = False
default_subfolder_monitor = True
default_run_process = True
default_folder_format = DEFAULT_FOLDER_FORMAT
if os.path.exists(env_path):
cfg = dotenv_values(env_path)
# Two persisted bases let the dated and project-name modes
# each remember their own folder pattern, so switching the
# dropdown doesn't lose whichever setup the user isn't
# currently using. Legacy fallback chain on the dated key
# picks up destinations configured by the Setup Node or a
# prior version of this node.
default_dated_base = cfg.get(
"DROPBOX_FOLDER_BASE_DATED",
cfg.get("DROPBOX_FOLDER_BASE", cfg.get("DROPBOX_FOLDER", default_dated_base)),
)
default_flat_base = cfg.get("DROPBOX_FOLDER_BASE_FLAT", default_flat_base)
default_encrypt = cfg.get("ENABLE_ENCRYPTION", "False").lower() == "true"
default_delete_enc = cfg.get("POST_DELETE_ENC", "False").lower() == "true"
default_subfolder_monitor = cfg.get("SUBFOLDER_MONITOR", "True").lower() == "true"
default_run_process = cfg.get("RUN_PROCESS", "True").lower() == "true"
default_folder_format = cfg.get("DROPBOX_FOLDER_FORMAT", default_folder_format)
if default_folder_format not in FOLDER_FORMAT_OPTIONS:
default_folder_format = DEFAULT_FOLDER_FORMAT
if default_folder_format == PROJECT_NAME_FORMAT:
default_dest = default_flat_base
else:
# Defensive: strip a trailing date in case a legacy
# DROPBOX_FOLDER value already had one appended.
default_dated_base = _strip_trailing_date(default_dated_base, default_folder_format)
default_dest = default_dated_base.rstrip("/") + "/" + _today_str(default_folder_format)
return {
"required": {
"watch_folder": ("STRING", {"default": default_watch}),
"dropbox_dest_folder": ("STRING", {"default": default_dest}),
"folder_format": (FOLDER_FORMAT_OPTIONS, {"default": default_folder_format}),
"enable_encryption": ("BOOLEAN", {"default": default_encrypt}),
"Post_Delete_Enc": ("BOOLEAN", {"default": default_delete_enc}),
"Subfolder_Monitor": ("BOOLEAN", {"default": default_subfolder_monitor}),
"run_process": ("BOOLEAN", {"default": default_run_process, "label": "Run Process"}),
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_NODE = True
FUNCTION = "start"
def start(self, watch_folder, dropbox_dest_folder, folder_format, enable_encryption, Post_Delete_Enc, Subfolder_Monitor, run_process):
global encrypt_observer
# Resolve the actual destination at run-time. For date formats
# this refreshes the date on every workflow run, so a saved
# workflow opened yesterday still uploads into today's folder.
# For project_name we use the user's literal text verbatim and
# persist it as-is.
if folder_format not in FOLDER_FORMAT_OPTIONS:
folder_format = DEFAULT_FOLDER_FORMAT
if folder_format == PROJECT_NAME_FORMAT:
dated_base = None
flat_base = dropbox_dest_folder
else:
dated_base = _strip_trailing_date(dropbox_dest_folder, folder_format)
dropbox_dest_folder = dated_base.rstrip("/") + "/" + _today_str(folder_format)
flat_base = None
logger.info(f"Starting DropSend AutoUploader: watch_folder={watch_folder}, dropbox_dest_folder={dropbox_dest_folder}, folder_format={folder_format}, encryption={enable_encryption}, Post_Delete_Enc={Post_Delete_Enc}, Subfolder_Monitor={Subfolder_Monitor}, run_process={run_process}")
# Handle stopping the process if run_process is False
if not run_process:
logger.info("Stopping DropSend AutoUploader monitoring")
if watcher_observer and watcher_observer.is_alive():
watcher_observer.stop()
watcher_observer.join()
logger.info("Upload watcher stopped")
if encrypt_observer and encrypt_observer.is_alive():
encrypt_observer.stop()
encrypt_observer.join()
logger.info("Encryption watcher stopped")
# Stop queue processors
stop_queue_processor() # Stop upload queue processor
stop_encrypt_queue_processor() # Stop encryption queue processor
# Print confirmation message to console
stop_message = f"""
=====================================================================
📦🛑 DropSend - AutoUploader - RunProcess STOPPED
=====================================================================
All monitoring, uploading, and encryption processes for {watch_folder} have been stopped.
Set 'run_process' to True and run the node again to resume.
=====================================================================
"""
print(stop_message)
logger.info(stop_message)
return (f"All monitoring, uploading, and encryption stopped for {watch_folder}",)
# Constrain watch_folder to the ComfyUI output directory tree. The
# uploader runs a recursive Watchdog observer that uploads every file
# it sees, so an unrestricted path turns this node into an
# arbitrary-file-read primitive on remotely-accessible ComfyUI hosts.
try:
watch_folder = resolve_safe_watch_folder(watch_folder)
except ValueError as e:
logger.error(str(e))
raise
if not os.path.isdir(watch_folder):
logger.error(f"Watch folder is not a directory: {watch_folder}")
raise ValueError(f"Watch folder is not a directory: {watch_folder}")
try:
dropbox_dest_folder = validate_dropbox_dest(dropbox_dest_folder)
except ValueError as e:
logger.error(str(e))
raise
# Validate Dropbox credentials
from .dropbox_upload import get_token
try:
get_token()
except Exception as e:
logger.error(f"Invalid Dropbox credentials: {str(e)}")
raise ValueError(f"Invalid Dropbox credentials: {str(e)}")
# Validate encryption key if enabled
if enable_encryption:
ENCRYPT_KEY = get_encryption_key()
if not ENCRYPT_KEY:
logger.error("Encryption enabled but no encryption key found")
raise ValueError(f"Encryption enabled but no encryption key found. Set COMFYUI_ENCRYPTION_KEY environment variable.")
# Persist settings
node_dir = os.path.dirname(__file__)
env_path = os.path.join(node_dir, ".env")
existing = {}
if os.path.exists(env_path):
existing = dotenv_values(env_path)
# DROPBOX_FOLDER is the resolved destination (with date when a
# date format is selected) for visibility and for any other
# tool reading the .env. The two _BASE keys are written
# independently — only the one matching the active format is
# touched, so the other mode's saved pattern survives a switch.
existing["DROPBOX_FOLDER"] = dropbox_dest_folder
if dated_base is not None:
existing["DROPBOX_FOLDER_BASE_DATED"] = dated_base
if flat_base is not None:
existing["DROPBOX_FOLDER_BASE_FLAT"] = flat_base
existing["DROPBOX_FOLDER_FORMAT"] = folder_format
existing["ENABLE_ENCRYPTION"] = str(enable_encryption)
existing["POST_DELETE_ENC"] = str(Post_Delete_Enc)
existing["SUBFOLDER_MONITOR"] = str(Subfolder_Monitor)
existing["RUN_PROCESS"] = str(run_process)
# Race-free open: the file is created with mode 0600 from the
# start. The merged file may include DROPBOX_REFRESH_TOKEN /
# COMFYUI_ENCRYPTION_KEY the user previously stored, so the
# window between create-with-default-perms and a follow-up
# chmod is exactly what we need to avoid.
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
for k, v in existing.items():
f.write(f"{k}={v}\n")
load_dotenv(env_path, override=True)
# Start processes only if run_process is True
if run_process:
# Build the encrypt handler up-front; start_monitoring will
# schedule it on the SAME Observer as the upload handler.
# macOS's _fsevents library refuses two separate watches on
# the same path (the second emitter dies with "already
# scheduled" and never delivers events), so we share one
# Observer with multiple handlers.
encrypt_handler = None
if enable_encryption:
encrypt_handler = FileEncryptHandler(watch_folder, False, Subfolder_Monitor)
# Run synchronously — Observer manages its own threads, so
# there is nothing to gain from wrapping this in a thread,
# and a thread introduces a startup race where the upload
# observer might not be ready before the first file lands.
start_monitoring(
watch_folder,
dropbox_dest_folder,
enable_encryption,
Post_Delete_Enc,
Subfolder_Monitor,
encrypt_handler=encrypt_handler,
)
if encrypt_handler is not None:
logger.info(f"Starting encryption monitor for {watch_folder}")
# Build a banner the user sees in the node's UI output and in
# the ComfyUI console. Each setting line is `key = value
# (plain-English meaning)` so the implications are obvious at
# a glance — no jumping back to the README to figure out what
# POST_DELETE_ENC does.
sub_desc = "subfolders watched recursively" if Subfolder_Monitor else "only the top-level folder is watched"
if folder_format == PROJECT_NAME_FORMAT:
fmt_desc = "your dropbox_dest_folder is used verbatim, e.g. /ComfyUI-Project/My-Project"
else:
fmt_desc = f"today's date in {folder_format} appended daily, folder refreshes each session"
enc_desc = "files encrypted (.enc) before upload" if enable_encryption else "files uploaded as-is, no encryption"
if enable_encryption:
del_desc = ".enc files deleted after upload verifies" if Post_Delete_Enc else ".enc files kept on disk after upload"
else:
del_desc = "ignored — only matters when enable_encryption is true"
banner = f"""
=====================================================================
📦📤 DropSend - AutoUploader - RUNNING
=====================================================================
Watching:
{watch_folder}
Uploading to:
{dropbox_dest_folder}
Settings:
enable_encryption = {str(enable_encryption).lower()} ({enc_desc})
folder_format = {folder_format} ({fmt_desc})
Post_Delete_Enc = {str(Post_Delete_Enc).lower()} ({del_desc})
Subfolder_Monitor = {str(Subfolder_Monitor).lower()} ({sub_desc})
run_process = {str(run_process).lower()} (monitoring active — set false and re-run to stop)
How to change settings while running:
• Edit any field on this node and re-run the workflow to apply.
• To switch from a date format to a project name: pick `project_name`
in folder_format, edit dropbox_dest_folder to your project name,
then re-run.
• To stop monitoring entirely: set run_process = false and re-run.
=====================================================================
"""
print(banner)
logger.info(banner)
return (banner,)
# Required mappings for ComfyUI
NODE_CLASS_MAPPINGS = {"DropSendAutoUploader": DropSendAutoUploaderNode}
NODE_DISPLAY_NAME_MAPPINGS = {"DropSendAutoUploader": "📦📤 DropSend - AutoUploader"}