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.
804 lines
38 KiB
Python
804 lines
38 KiB
Python
# dropsend_setup_node.py
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import threading
|
|
import requests
|
|
import webbrowser
|
|
import uuid
|
|
import json
|
|
from dotenv import load_dotenv, dotenv_values
|
|
import urllib.parse
|
|
from .dropbox_auth_manager import DropboxAuthManager
|
|
from cryptography.fernet import Fernet
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Out-of-band secret transport
|
|
#
|
|
# ComfyUI persists every queued prompt's input JSON in
|
|
# PromptServer.history, which is exposed on the unauthenticated
|
|
# /history endpoint. If the Setup Node accepts app_key/app_secret/
|
|
# auth_code as workflow inputs, those values are visible there for the
|
|
# lifetime of history entry — a real exposure on any network-reachable
|
|
# pod (RunPod tunnel, public ComfyUI UI, etc.).
|
|
#
|
|
# To close it: the JS extension intercepts Queue, POSTs the secret
|
|
# widget values to /dropsend/setup/stash (this module's route), clears
|
|
# the widgets, then lets the queue go through. The prompt JSON ComfyUI
|
|
# stores in history therefore has empty strings for the secret fields.
|
|
# When this node's setup() actually runs, it pulls the real values
|
|
# from the stash (one-shot, keyed by the originating client_id).
|
|
#
|
|
# Entries auto-expire after _SETUP_STASH_TTL_SEC so a stash POST that's
|
|
# never consumed (user closed the tab between stash and queue, etc.)
|
|
# doesn't sit in memory.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_setup_secret_stash = {}
|
|
_setup_secret_lock = threading.Lock()
|
|
_SETUP_STASH_TTL_SEC = 60
|
|
_SETUP_STASH_MAX_ENTRIES = 32 # cap to bound memory under stash-spam abuse
|
|
_CLIENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
|
|
# Per-IP rate limit on the stash route. Defense-in-depth against an
|
|
# attacker on the same network firing thousands of stash POSTs to
|
|
# evict a legitimate user's entry between Save and Queue (the entry
|
|
# eviction policy is oldest-first to favour fresh writes, but a
|
|
# sufficiently fast attacker can still race).
|
|
_STASH_RL_WINDOW_SEC = 10
|
|
_STASH_RL_MAX_PER_WINDOW = 30
|
|
_stash_rl_state = {}
|
|
_stash_rl_lock = threading.Lock()
|
|
|
|
|
|
def _stash_rl_check(remote_ip):
|
|
"""Return True if `remote_ip` is under the per-window POST budget,
|
|
False if it should be 429'd. Updates the bucket in place."""
|
|
if not remote_ip:
|
|
return True # don't block requests we can't attribute
|
|
now = time.time()
|
|
cutoff = now - _STASH_RL_WINDOW_SEC
|
|
with _stash_rl_lock:
|
|
# Lazy GC: only walk the dict every ~50 hits, otherwise this
|
|
# is O(N) per request and turns into its own DoS.
|
|
if len(_stash_rl_state) > 256:
|
|
stale = [ip for ip, hits in _stash_rl_state.items() if not hits or hits[-1] < cutoff]
|
|
for ip in stale:
|
|
_stash_rl_state.pop(ip, None)
|
|
hits = _stash_rl_state.setdefault(remote_ip, [])
|
|
# Drop hits outside the window.
|
|
i = 0
|
|
while i < len(hits) and hits[i] < cutoff:
|
|
i += 1
|
|
if i:
|
|
del hits[:i]
|
|
if len(hits) >= _STASH_RL_MAX_PER_WINDOW:
|
|
return False
|
|
hits.append(now)
|
|
return True
|
|
|
|
|
|
def _stash_prune_expired_locked():
|
|
now = time.time()
|
|
expired = [k for k, (ts, _v) in _setup_secret_stash.items() if now - ts > _SETUP_STASH_TTL_SEC]
|
|
for k in expired:
|
|
_setup_secret_stash.pop(k, None)
|
|
|
|
|
|
def _stash_set(client_id, payload):
|
|
with _setup_secret_lock:
|
|
_stash_prune_expired_locked()
|
|
if len(_setup_secret_stash) >= _SETUP_STASH_MAX_ENTRIES and client_id not in _setup_secret_stash:
|
|
# Evict the oldest entry rather than refuse the write — the
|
|
# legitimate user's most recent attempt should win over
|
|
# whatever's been sitting around.
|
|
oldest_key = min(_setup_secret_stash, key=lambda k: _setup_secret_stash[k][0])
|
|
_setup_secret_stash.pop(oldest_key, None)
|
|
_setup_secret_stash[client_id] = (time.time(), payload)
|
|
|
|
|
|
def _stash_consume(client_id):
|
|
with _setup_secret_lock:
|
|
_stash_prune_expired_locked()
|
|
entry = _setup_secret_stash.pop(client_id, None)
|
|
if entry is None:
|
|
return None
|
|
_ts, payload = entry
|
|
return payload
|
|
|
|
|
|
def _register_stash_route_once():
|
|
"""Register the /dropsend/setup/stash POST handler on PromptServer.
|
|
|
|
Idempotent — repeat calls are no-ops. Called at module import; if
|
|
PromptServer isn't ready yet, registration is silently skipped (the
|
|
Setup Node falls back to reading secrets from prompt inputs, the
|
|
legacy/insecure path).
|
|
"""
|
|
if getattr(_register_stash_route_once, "_registered", False):
|
|
return
|
|
try:
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
except Exception:
|
|
return
|
|
instance = getattr(PromptServer, "instance", None)
|
|
if instance is None or not hasattr(instance, "routes"):
|
|
return
|
|
|
|
@instance.routes.post("/dropsend/setup/stash")
|
|
async def _stash_handler(request):
|
|
# Same-origin enforcement. The browser sends an Origin header on
|
|
# POSTs; we require it to match the host the request was made to.
|
|
# Without this, a malicious page in another tab could attempt a
|
|
# CSRF POST against http://127.0.0.1:8188 — and while the live-
|
|
# WebSocket-session check would block injection without a known
|
|
# client_id, an attacker who sniffed the WS could still try.
|
|
# Origin check closes the cross-origin path entirely.
|
|
origin = request.headers.get("Origin", "")
|
|
if origin:
|
|
try:
|
|
from urllib.parse import urlparse
|
|
origin_host = urlparse(origin).netloc
|
|
except Exception:
|
|
origin_host = ""
|
|
if not origin_host or origin_host != request.host:
|
|
return web.json_response({"error": "cross-origin denied"}, status=403)
|
|
|
|
# Content-Type lockdown: only accept JSON. Closes the confused-
|
|
# deputy class where a form-encoded body is parsed as JSON.
|
|
if request.content_type and request.content_type != "application/json":
|
|
return web.json_response(
|
|
{"error": "Content-Type must be application/json"},
|
|
status=415,
|
|
)
|
|
|
|
# Per-IP rate limit. Defense-in-depth against stash-spam DoS
|
|
# attempting to evict a legitimate user's entry by overflowing
|
|
# the entry cap.
|
|
remote_ip = request.headers.get("X-Forwarded-For", request.remote or "").split(",")[0].strip()
|
|
if not _stash_rl_check(remote_ip):
|
|
return web.json_response({"error": "rate limit"}, status=429)
|
|
|
|
# Reject oversized bodies before json-decoding to avoid a
|
|
# malicious large-payload memory spike.
|
|
if request.content_length is not None and request.content_length > 32 * 1024:
|
|
return web.json_response({"error": "payload too large"}, status=413)
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response({"error": "invalid json"}, status=400)
|
|
client_id = data.get("client_id") if isinstance(data, dict) else None
|
|
if not client_id or not isinstance(client_id, str) or not _CLIENT_ID_PATTERN.match(client_id):
|
|
return web.json_response({"error": "client_id required"}, status=400)
|
|
|
|
# Authenticate by liveness: only accept stash writes for a
|
|
# client_id that currently has an active WebSocket connection
|
|
# to this PromptServer instance. Closes the "attacker on the
|
|
# same network injects credentials by guessing a victim's
|
|
# client_id" vector — the attacker would need both the
|
|
# client_id AND a live WS bound to it under that ID.
|
|
try:
|
|
sockets = getattr(instance, "sockets", None)
|
|
if not sockets or client_id not in sockets:
|
|
return web.json_response({"error": "client_id has no active WebSocket"}, status=403)
|
|
except Exception:
|
|
return web.json_response({"error": "internal"}, status=500)
|
|
|
|
payload = {
|
|
"app_key": str(data.get("app_key") or ""),
|
|
"app_secret": str(data.get("app_secret") or ""),
|
|
"auth_code": str(data.get("auth_code") or ""),
|
|
}
|
|
# Drop entries that have nothing to stash, saves memory and
|
|
# avoids weird empty-stash-overrides-real-input behaviour.
|
|
if not any(payload.values()):
|
|
return web.json_response({"ok": True, "stashed": False})
|
|
_stash_set(client_id, payload)
|
|
return web.json_response({"ok": True, "stashed": True})
|
|
|
|
_register_stash_route_once._registered = True
|
|
print("[DropSend Setup] Registered /dropsend/setup/stash route (out-of-band secret transport)")
|
|
|
|
|
|
_register_stash_route_once()
|
|
|
|
|
|
# Workflow inputs to the setup node can come from any caller that can submit
|
|
# a workflow to ComfyUI. On a network-reachable host that means a remote
|
|
# attacker can (a) wipe credentials via reconnect=True or (b) overwrite the
|
|
# stored Dropbox app to redirect uploads to their own account. We therefore
|
|
# require the operator to opt in by setting this env var on the host before
|
|
# the setup node will perform any state-changing action.
|
|
_SETUP_OPT_IN_VAR = "COMFYUI_DROPSEND_ALLOW_SETUP"
|
|
_SETUP_OPT_IN_HELP = (
|
|
f"Setup is disabled. To allow this node to write or clear credentials, "
|
|
f"set {_SETUP_OPT_IN_VAR}=1 in the host environment before starting "
|
|
f"ComfyUI, or provision DROPBOX_APP_KEY / DROPBOX_APP_SECRET / "
|
|
f"DROPBOX_REFRESH_TOKEN directly via environment variables (e.g. RunPod "
|
|
f"secrets). This guard prevents remote workflow submitters from "
|
|
f"hijacking or wiping your Dropbox connection."
|
|
)
|
|
|
|
|
|
def _setup_opt_in_enabled():
|
|
return os.getenv(_SETUP_OPT_IN_VAR, "").strip().lower() in ("1", "true", "yes")
|
|
|
|
|
|
# Names of fields treated as secrets when redacting the console banner.
|
|
# Every field the Setup Node emits is a credential, so all four are
|
|
# redacted. The destination folder is owned by the AutoUploader and
|
|
# never passes through this node.
|
|
_SECRET_FIELDS = {
|
|
"DROPBOX_APP_KEY",
|
|
"DROPBOX_APP_SECRET",
|
|
"DROPBOX_REFRESH_TOKEN",
|
|
"COMFYUI_ENCRYPTION_KEY",
|
|
}
|
|
|
|
|
|
def _redact(value, visible_tail=4):
|
|
"""Return value with all but the last few characters masked."""
|
|
if not value:
|
|
return ""
|
|
if len(value) <= visible_tail:
|
|
return "*" * len(value)
|
|
return "*" * (len(value) - visible_tail) + value[-visible_tail:]
|
|
|
|
|
|
def _redact_kv_line(line):
|
|
"""Redact a 'NAME=value' line if NAME is in _SECRET_FIELDS."""
|
|
name, sep, value = line.partition("=")
|
|
if sep and name in _SECRET_FIELDS:
|
|
return f"{name}={_redact(value)}"
|
|
return line
|
|
|
|
class DropSendSetupNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Architecture: this node has NO credential-bearing inputs.
|
|
# app_key, app_secret, and auth_code never enter the workflow
|
|
# prompt JSON, never reach PromptServer.history, never get
|
|
# serialized into PNG metadata or saved workflows. They are
|
|
# entered by the user in a browser-only modal launched from
|
|
# the JS extension's "Set credentials…" button, POSTed
|
|
# directly to the same-origin route /dropsend/setup/stash, and
|
|
# consumed once on the next Queue from the in-process stash
|
|
# keyed by client_id. The only inputs on the node are
|
|
# configuration knobs that aren't sensitive.
|
|
return {
|
|
"required": {
|
|
"storage_method": (["env_file", "display_only"], {
|
|
"label": "Credential Storage Method",
|
|
"default": "display_only"
|
|
}),
|
|
"encryption_key_method": (["off", "Display Only", "save to .env"], {
|
|
"label": "Encryption Key Method",
|
|
"default": "Display Only"
|
|
}),
|
|
},
|
|
"optional": {
|
|
"reconnect": ("BOOLEAN", {
|
|
"label": "Reset stored credentials",
|
|
"default": False
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "setup"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, *args, **kwargs):
|
|
# Force re-execution on every Queue. Setup is intentionally
|
|
# side-effecting (writes/clears .env, exchanges auth codes,
|
|
# refreshes tokens) and the input dict here doesn't reflect
|
|
# the actual work — the real driver is the out-of-band stash
|
|
# entry, which ComfyUI's input-hash cache cannot see. Always
|
|
# returning NaN means setup() runs every time the user clicks
|
|
# Queue, which is the correct semantics for this node.
|
|
return float("NaN")
|
|
|
|
def setup(self, storage_method="display_only", encryption_key_method="Display Only", reconnect=False):
|
|
try:
|
|
print(f"[DropSend Setup] Called: reconnect={reconnect}, storage_method={storage_method}, encryption_key_method={encryption_key_method}")
|
|
|
|
# Credentials live exclusively in the per-session out-of-band
|
|
# stash keyed by client_id, populated by the browser modal
|
|
# via POST /dropsend/setup/stash. There are no fallback
|
|
# paths — if the stash is empty for this client, the user
|
|
# has to click "Set credentials…" on the node and enter
|
|
# values there. This is the architectural guarantee that
|
|
# /history, PNG metadata, workflow JSON, and localStorage
|
|
# never see the secret fields.
|
|
app_key = None
|
|
app_secret = None
|
|
auth_code = None
|
|
try:
|
|
from server import PromptServer
|
|
sid = getattr(PromptServer.instance, "client_id", None)
|
|
if sid:
|
|
stashed = _stash_consume(sid)
|
|
if stashed:
|
|
app_key = stashed.get("app_key") or None
|
|
app_secret = stashed.get("app_secret") or None
|
|
auth_code = stashed.get("auth_code") or None
|
|
print("[DropSend Setup] Loaded secrets from out-of-band stash")
|
|
except Exception as e:
|
|
print(f"[DropSend Setup] Warning: could not consume secret stash: {e}")
|
|
|
|
print(f"[DropSend Setup] Inputs present: app_key={bool(app_key)}, app_secret={bool(app_secret)}, auth_code={bool(auth_code)}")
|
|
|
|
# Initialize auth manager
|
|
auth_manager = DropboxAuthManager()
|
|
print(f"[DropSend Setup] Auth manager initialized")
|
|
|
|
# Handle reconnect/reset request
|
|
if reconnect:
|
|
if not _setup_opt_in_enabled():
|
|
message = (
|
|
"Reconnect refused: " + _SETUP_OPT_IN_HELP
|
|
)
|
|
print(f"[DropSend Setup] {message}")
|
|
return {"ui": {"text": [message]}, "result": (message,)}
|
|
|
|
print("[DropSend Setup] Reconnect requested - clearing all credentials")
|
|
|
|
# Populate the auth manager from os.environ (which
|
|
# was loaded from .env at module import) so the reset
|
|
# call has the credentials needed to revoke the
|
|
# refresh token at Dropbox. Without this, a leaked
|
|
# refresh_token + app_secret pair would still be valid
|
|
# at Dropbox until manually revoked. Revocation is
|
|
# best-effort with an 8-second timeout (see
|
|
# DropboxAuthManager.reset) — local cleanup proceeds
|
|
# regardless.
|
|
auth_manager.app_key = os.environ.get("DROPBOX_APP_KEY") or auth_manager.app_key
|
|
auth_manager.app_secret = os.environ.get("DROPBOX_APP_SECRET") or auth_manager.app_secret
|
|
auth_manager.refresh_token = os.environ.get("DROPBOX_REFRESH_TOKEN") or auth_manager.refresh_token
|
|
|
|
print("[DropSend Setup] Clearing all stored credentials")
|
|
auth_manager.reset(revoke_token=True)
|
|
|
|
# Manually clear all credential files
|
|
node_dir = os.path.dirname(__file__)
|
|
|
|
# Clear .env file if it exists
|
|
env_path = os.path.join(node_dir, ".env")
|
|
if os.path.exists(env_path):
|
|
print(f"[DropSend Setup] Removing .env file: {env_path}")
|
|
os.remove(env_path)
|
|
|
|
# Clear in-process os.environ for the credential keys.
|
|
# __init__.py's load_dotenv() copies .env values into
|
|
# os.environ at module import; deleting .env on disk
|
|
# does not unset those copies. Without this, the next
|
|
# Setup run hits the `env_vars_set` short-circuit at
|
|
# line ~165 and returns "credentials found in system
|
|
# environment variables" — even though the user is
|
|
# mid-reconfigure and expects to enter a new auth code.
|
|
# We don't touch DROPBOX_FOLDER (a path, not a credential)
|
|
# or COMFYUI_ENCRYPTION_KEY (kept across reconnects).
|
|
for key in ("DROPBOX_APP_KEY", "DROPBOX_APP_SECRET", "DROPBOX_REFRESH_TOKEN", "DROPBOX_ACCESS_TOKEN"):
|
|
if key in os.environ:
|
|
os.environ.pop(key, None)
|
|
print(f"[DropSend Setup] Cleared {key} from process environment")
|
|
|
|
# Send WebSocket notification to the originating browser
|
|
# so its auth fields refresh. Refuse to broadcast — if
|
|
# there's no client_id (e.g. CLI submission without one
|
|
# in the prompt JSON), every connected browser tab on
|
|
# this ComfyUI instance would otherwise refresh.
|
|
try:
|
|
from server import PromptServer
|
|
sid = getattr(PromptServer.instance, "client_id", None)
|
|
if not sid:
|
|
print(
|
|
"[DropSend Setup] Skipping reconnect-complete "
|
|
"broadcast: prompt has no client_id (CLI "
|
|
"submission?). Submit from the ComfyUI web UI "
|
|
"to receive the refresh notification."
|
|
)
|
|
else:
|
|
message_data = {
|
|
"type": "dropbox_reconnect_complete",
|
|
"success": True,
|
|
"client_id": sid,
|
|
"message": "Credentials cleared - ComfyUI will refresh to show auth fields",
|
|
}
|
|
PromptServer.instance.send_sync(
|
|
"dropbox_reconnect_complete", message_data, sid
|
|
)
|
|
print(f"[DropSend Setup] Sent WebSocket notification for reconnect completion")
|
|
except Exception as e:
|
|
print(f"[DropSend Setup] Warning: Could not send WebSocket notification: {e}")
|
|
|
|
message = "Dropbox credentials cleared. ComfyUI will refresh to show auth fields..."
|
|
print(f"[DropSend Setup] {message}")
|
|
return {
|
|
"ui": {"text": [message]},
|
|
"result": (message,)
|
|
}
|
|
|
|
# Check for environment variables (from any source)
|
|
env_vars_set = all([
|
|
os.getenv("DROPBOX_APP_KEY"),
|
|
os.getenv("DROPBOX_APP_SECRET"),
|
|
os.getenv("DROPBOX_REFRESH_TOKEN")
|
|
])
|
|
if env_vars_set:
|
|
message = "Dropbox credentials found in system environment variables. Ready to upload files."
|
|
print(f"[DropSend Setup] {message}")
|
|
return {
|
|
"ui": {"text": [message]},
|
|
"result": (message,)
|
|
}
|
|
|
|
# Check for RunPod secrets
|
|
runpod_env_set = all([
|
|
os.getenv("RUNPOD_SECRET_DROPBOX_ACCESS_TOKEN"),
|
|
os.getenv("RUNPOD_SECRET_DROPBOX_REFRESH_TOKEN")
|
|
])
|
|
if runpod_env_set:
|
|
return ("Warning: Detected RunPod secrets. Using those instead.",)
|
|
|
|
# New setup flow using DropboxAuthManager.
|
|
#
|
|
# Below this point the node accepts Dropbox app credentials from
|
|
# workflow inputs and persists them. On a remotely-reachable
|
|
# ComfyUI instance that flow is exploitable — a workflow author
|
|
# could swap in their own Dropbox app to redirect uploads. Gate
|
|
# it behind an explicit operator opt-in.
|
|
if not _setup_opt_in_enabled():
|
|
message = _SETUP_OPT_IN_HELP
|
|
print(f"[DropSend Setup] {message}")
|
|
return {"ui": {"text": [message]}, "result": (message,)}
|
|
|
|
print(f"[DropSend Setup] Starting new setup flow")
|
|
|
|
# Clean up the inputs first
|
|
app_key_clean = app_key.strip() if app_key else ""
|
|
app_secret_clean = app_secret.strip() if app_secret else ""
|
|
auth_code_clean = auth_code.strip() if auth_code else ""
|
|
|
|
# Log only the lengths, not the values themselves.
|
|
print(f"[DropSend Setup] Cleaned input lengths: app_key={len(app_key_clean)}, app_secret={len(app_secret_clean)}, auth_code={len(auth_code_clean)}")
|
|
|
|
# Check if we have app credentials
|
|
if not app_key_clean or not app_secret_clean:
|
|
message = "Error: Missing App Key or App Secret. Please provide both."
|
|
print(f"[DropSend Setup] {message}")
|
|
return (message,)
|
|
|
|
# If no auth code, generate OAuth URL for manual code flow
|
|
if not auth_code_clean:
|
|
print(f"[DropSend Setup] No auth code provided - generating OAuth URL for manual flow")
|
|
auth_temp = DropboxAuthManager(app_key=app_key_clean)
|
|
|
|
# Manual OAuth flow without redirect_uri (Dropbox will display the code)
|
|
oauth_url = auth_temp.get_oauth_url(force_reapprove=True)
|
|
|
|
# Print a high-visibility banner with the OAuth URL so the
|
|
# operator can see it directly in the ComfyUI terminal
|
|
# without needing to wire a Show Text node to the Setup
|
|
# Node's output. The URL contains only the OAuth client_id
|
|
# (Dropbox app_key) — semi-public per Dropbox's design,
|
|
# not a credential — so it's safe to print to console.
|
|
print()
|
|
print("=" * 80)
|
|
print("DROPSEND — DROPBOX AUTHORIZATION REQUIRED")
|
|
print("=" * 80)
|
|
print("Open this URL in your browser to authorize:")
|
|
print()
|
|
print(f" {oauth_url}")
|
|
print()
|
|
print("After authorizing, Dropbox will display an authorization code.")
|
|
print("Paste it into the credentials modal that auto-opened in your")
|
|
print("browser, then click Save and re-queue this workflow.")
|
|
print("=" * 80)
|
|
print()
|
|
|
|
# Auto-open the credentials-entry modal in the originating
|
|
# browser so the user can paste the auth_code as soon as
|
|
# Dropbox shows it. Same client_id-only delivery pattern
|
|
# we use for the credentials_ready event — refuse if
|
|
# there's no client_id (CLI submission), otherwise the
|
|
# event would broadcast to every connected browser tab.
|
|
try:
|
|
from server import PromptServer
|
|
sid = getattr(PromptServer.instance, "client_id", None)
|
|
if sid:
|
|
PromptServer.instance.send_sync(
|
|
"dropsend_credentials_needed",
|
|
{"client_id": sid, "stage": "auth_code"},
|
|
sid,
|
|
)
|
|
print("[DropSend Setup] Sent credentials-needed event to browser (auto-opens modal for auth_code paste)")
|
|
except Exception as e:
|
|
print(f"[DropSend Setup] Warning: could not send credentials-needed event: {e}")
|
|
|
|
message = (
|
|
f"Dropbox OAuth Ready!\n\n"
|
|
f"Click the link below to authorize with Dropbox "
|
|
f"(also printed in the ComfyUI terminal):\n\n"
|
|
f"{oauth_url}\n\n"
|
|
f"A popup window will open. After authorization, Dropbox "
|
|
f"will show your auth code.\nCopy the code and paste it "
|
|
f"into the 'auth_code' field above, then run this node again."
|
|
)
|
|
|
|
# Use ComfyUI's dynamic return format for better UI integration
|
|
return {
|
|
"ui": {"text": [message]},
|
|
"result": (message,)
|
|
}
|
|
|
|
# Exchange auth code for refresh token using DropboxAuthManager
|
|
print(f"[DropSend Setup] Attempting to exchange auth code")
|
|
auth_manager_setup = DropboxAuthManager(app_key_clean, app_secret_clean)
|
|
|
|
# Get the tokens without storing them yet
|
|
# Use manual OAuth flow (auth codes from manual copy/paste)
|
|
print(f"[DropSend Setup] Using manual OAuth flow")
|
|
result = auth_manager_setup.exchange_auth_code_raw(auth_code_clean)
|
|
refresh_token = result.get("refresh_token")
|
|
|
|
print(f"[DropSend Setup] Auth code exchange successful")
|
|
print(f"[DropSend Setup] Using storage method: {storage_method}")
|
|
|
|
# Generate encryption key only if encryption_key_method is not "off"
|
|
encryption_key = None
|
|
if encryption_key_method != "off":
|
|
encryption_key = Fernet.generate_key().decode()
|
|
|
|
# Route each credential to one of two destinations based on the
|
|
# user's two independent choices:
|
|
#
|
|
# storage_method = env_file | display_only
|
|
# encryption_key_method= save to .env | Display Only | off
|
|
#
|
|
# `env_file` / `save to .env` -> persist to .env on disk
|
|
# `display_only` / `Display Only` -> deliver to the originating
|
|
# browser via a WebSocket side-channel that does NOT pass
|
|
# through the node's `ui` or `result`. Both of those land in
|
|
# PromptServer.history (see ComfyUI execution.py — the
|
|
# history_result["outputs"] dict is built from ui_outputs),
|
|
# which is served on the unauthenticated /history endpoint
|
|
# and would expose any credential placed there. Cloud users
|
|
# (RunPod, Docker) explicitly choose `display_only` to keep
|
|
# credentials out of the pod's filesystem entirely.
|
|
env_writes = [] # (NAME, value) pairs to persist to .env
|
|
ws_payload = {} # NAME -> value, delivered via WebSocket only
|
|
|
|
dropbox_creds = [
|
|
("DROPBOX_APP_KEY", app_key_clean),
|
|
("DROPBOX_APP_SECRET", app_secret_clean),
|
|
("DROPBOX_REFRESH_TOKEN", refresh_token),
|
|
]
|
|
if storage_method == "env_file":
|
|
env_writes.extend(dropbox_creds)
|
|
else:
|
|
for name, value in dropbox_creds:
|
|
ws_payload[name] = value
|
|
|
|
if encryption_key_method == "save to .env" and encryption_key:
|
|
env_writes.append(("COMFYUI_ENCRYPTION_KEY", encryption_key))
|
|
elif encryption_key_method == "Display Only" and encryption_key:
|
|
ws_payload["COMFYUI_ENCRYPTION_KEY"] = encryption_key
|
|
|
|
# Merge with any existing .env so re-running setup is non-destructive.
|
|
#
|
|
# - Non-setup keys (e.g. the AutoUploader's ENABLE_ENCRYPTION,
|
|
# POST_DELETE_ENC, etc.) survive a setup re-run unchanged.
|
|
# - Setup keys whose destination this run is NOT the .env file
|
|
# are dropped from disk: either they're being rewritten from
|
|
# env_writes, or they're being delivered via the browser-only
|
|
# WebSocket and the old on-disk copy is now stale. Switching
|
|
# from env_file -> display_only therefore actually scrubs the
|
|
# credential lines off disk, instead of leaving stale secrets
|
|
# in .env.
|
|
# - The race-free open (O_CREAT with mode 0o600) keeps the file
|
|
# from being world/group-readable in the open->chmod window.
|
|
# Keys the Setup Node "owns" — these get scrubbed from .env
|
|
# on a setup re-run that's not writing them back, so old
|
|
# values don't linger when switching env_file -> display_only.
|
|
# DROPBOX_FOLDER is intentionally absent: it's owned by the
|
|
# AutoUploader, not Setup, and would otherwise get wiped
|
|
# whenever the user re-runs Setup to rotate credentials.
|
|
SETUP_KEYS = {
|
|
"DROPBOX_APP_KEY",
|
|
"DROPBOX_APP_SECRET",
|
|
"DROPBOX_REFRESH_TOKEN",
|
|
"COMFYUI_ENCRYPTION_KEY",
|
|
}
|
|
|
|
node_dir = os.path.dirname(__file__)
|
|
env_path = os.path.join(node_dir, ".env")
|
|
|
|
existing = {}
|
|
if os.path.exists(env_path):
|
|
existing = dict(dotenv_values(env_path))
|
|
|
|
preserved = {k: v for k, v in existing.items() if k not in SETUP_KEYS}
|
|
merged = dict(preserved)
|
|
for name, value in env_writes:
|
|
merged[name] = value
|
|
|
|
if merged:
|
|
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 merged.items():
|
|
f.write(f"{k}={v}\n")
|
|
if env_writes:
|
|
# Load the freshly-written values into os.environ so
|
|
# they are usable by the AutoUploader (or any other
|
|
# node) in the same ComfyUI session, without requiring
|
|
# a restart.
|
|
load_dotenv(env_path, override=True)
|
|
# If env_writes is empty (e.g. display_only re-run that
|
|
# only scrubbed credentials), don't load — the new file
|
|
# has no credentials we'd want to push into os.environ.
|
|
elif os.path.exists(env_path):
|
|
# No setup keys this run AND nothing else worth keeping —
|
|
# remove the empty-shell file rather than leave a 0-byte
|
|
# 0600 file behind.
|
|
os.remove(env_path)
|
|
|
|
# Deliver the browser-only payload via PromptServer's WebSocket
|
|
# to the originating client. This data is NOT included in the
|
|
# node's `ui` or `result` and is therefore never persisted in
|
|
# PromptServer.history.
|
|
#
|
|
# Security: refuse to send if the prompt has no client_id.
|
|
# ComfyUI's send_sync with sid=None broadcasts to *every*
|
|
# connected WebSocket client (server.py send_json: when sid
|
|
# is None it iterates self.sockets). On a network-reachable
|
|
# host (RunPod tunnel, LAN, etc.) that means a CLI/curl/SDK
|
|
# submission without a client_id field would push the
|
|
# credentials to any browser tab pointed at the same ComfyUI
|
|
# instance. So we treat "no client_id" as a delivery failure
|
|
# and surface it to the operator instead.
|
|
ws_delivered = False
|
|
ws_delivery_error = None
|
|
if ws_payload:
|
|
try:
|
|
from server import PromptServer
|
|
sid = getattr(PromptServer.instance, "client_id", None)
|
|
if not sid:
|
|
ws_delivery_error = (
|
|
"prompt has no client_id (typical for CLI/curl/SDK "
|
|
"submissions). display_only delivery requires "
|
|
"submitting from the ComfyUI web browser so a "
|
|
"client_id is attached to the prompt."
|
|
)
|
|
msg = (
|
|
"[DropSend Setup] Refusing to deliver credentials: "
|
|
"prompt has no client_id. Sending to sid=None would "
|
|
"broadcast to every connected browser. Submit from "
|
|
"the ComfyUI web UI instead."
|
|
)
|
|
print(msg)
|
|
try:
|
|
import logging as _logging
|
|
_logging.getLogger(__name__).warning(msg)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Echo the sid inside the payload so the JS handler
|
|
# can defense-in-depth verify it matches api.clientId
|
|
# and ignore mismatched events.
|
|
PromptServer.instance.send_sync(
|
|
"dropsend_credentials_ready",
|
|
{"credentials": dict(ws_payload), "client_id": sid},
|
|
sid,
|
|
)
|
|
ws_delivered = True
|
|
except Exception as e:
|
|
ws_delivery_error = str(e)
|
|
print(f"[DropSend Setup] WebSocket delivery failed: {e}")
|
|
|
|
# Build the result/ui message — contains NO credential values.
|
|
# Both `ui` and `result` are persisted in PromptServer.history.
|
|
# Keep it short and action-oriented: tell the user the one
|
|
# thing to do next.
|
|
message_parts = ["Dropbox connected."]
|
|
if ws_payload and ws_delivered:
|
|
# display_only path (covers the mixed case where env_writes
|
|
# also happened — the panel still has values the user must
|
|
# manually copy into platform secrets before they're useful).
|
|
# Two-step framing: most cloud platforms (RunPod, Docker,
|
|
# K8s) require a container/pod restart for newly-added
|
|
# secrets to take effect. So "copy then run" is wrong;
|
|
# the correct flow is "copy, then configure secrets, then
|
|
# restart the pod, then on the new pod remove the Setup
|
|
# node and run AutoUploader".
|
|
message_parts.append(
|
|
"Next steps:\n"
|
|
" 1. Copy each value from the DropSend Credentials "
|
|
"panel to a safe place (password manager, secure note).\n"
|
|
" 2. Add them to your platform's secrets configuration "
|
|
"(RunPod Secrets, Docker env, systemd EnvironmentFile).\n"
|
|
" 3. Restart your pod / container so the new secrets "
|
|
"are visible to the ComfyUI process.\n"
|
|
" 4. On the restarted pod, remove this Setup node "
|
|
"from your workflow and run the 'DropSend - AutoUploader' "
|
|
"node."
|
|
)
|
|
elif ws_payload and not ws_delivered:
|
|
# Failure-mode message. The two audiences and what each
|
|
# should do:
|
|
# • Cloud users (RunPod, hosted) using display_only —
|
|
# must submit from the web UI so a client_id is on
|
|
# the prompt. They should NOT switch to env_file
|
|
# because that puts secrets on the pod's filesystem,
|
|
# which is exactly what display_only is designed to
|
|
# avoid.
|
|
# • Local users — env_file is a reasonable fallback.
|
|
message_parts.append(
|
|
f"Browser delivery refused: {ws_delivery_error}\n\n"
|
|
f"What to do:\n"
|
|
f" • Cloud / RunPod / hosted ComfyUI: Submit this "
|
|
f"workflow from your web browser (not via curl / API / "
|
|
f"SDK). The browser's WebSocket connection attaches a "
|
|
f"client_id automatically. display_only never writes "
|
|
f"credentials to the pod's filesystem.\n"
|
|
f" • Local install only: If you're running ComfyUI on "
|
|
f"the same machine you're using and don't mind "
|
|
f"credentials in a 0600 .env file on disk, switch "
|
|
f"storage_method to 'env_file' and rerun."
|
|
)
|
|
elif env_writes:
|
|
# env_file path: creds are already loaded into the running
|
|
# process; user just needs to remove the Setup node.
|
|
message_parts.append(
|
|
"You can remove this Setup node from your workflow "
|
|
"and run the 'DropSend - AutoUploader' node."
|
|
)
|
|
elif existing and any(k in SETUP_KEYS for k in existing):
|
|
# Re-run that scrubbed previously-written credential lines
|
|
# from .env (typical when switching env_file -> display_only).
|
|
message_parts.append(
|
|
"Previous credentials in .env were cleared because "
|
|
"this run delivers them another way."
|
|
)
|
|
|
|
message = "\n\n".join(message_parts)
|
|
|
|
# Console banner — confirmation only, values redacted. The
|
|
# operator who controls the host can verify what was set and
|
|
# where, without secrets going to captured stdout.
|
|
print("\n" + "=" * 80)
|
|
print("DROPSEND SETUP COMPLETE (values redacted in console)")
|
|
if env_writes:
|
|
print(f"Wrote to file: {env_path}")
|
|
for name, value in env_writes:
|
|
print(" " + _redact_kv_line(f"{name}={value}"))
|
|
if ws_payload:
|
|
label = "Sent to browser:" if ws_delivered else "FAILED to send to browser:"
|
|
print(label)
|
|
for name, value in ws_payload.items():
|
|
print(" " + _redact_kv_line(f"{name}={value}"))
|
|
print("=" * 80 + "\n")
|
|
|
|
print(f"[DropSend Setup] {message}")
|
|
|
|
return {
|
|
"ui": {"text": [message]},
|
|
"result": (message,),
|
|
}
|
|
|
|
except Exception as e:
|
|
message = f"Error: Setup failed: {e}"
|
|
print(f"[DropSend Setup] ERROR: {message}")
|
|
return {
|
|
"ui": {"text": [message]},
|
|
"result": (message,)
|
|
}
|
|
|
|
# Required mappings for ComfyUI
|
|
NODE_CLASS_MAPPINGS = {"DropSendSetup": DropSendSetupNode}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {"DropSendSetup": "📦⚙️ DropSend - Setup Node"}
|