Files
MachinePainting 4f85fcadfa v1.1.0: Security hardening parity with DropSend (untested)
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
2026-05-09 07:52:42 -07:00

305 lines
12 KiB
Python

# drivesend_uploader_node.py
# DriveSend AutoUploader Node - monitors output folder and uploads to Google Drive
import os
import threading
import logging
import time
from pathlib import Path
from dotenv import dotenv_values, load_dotenv
from watchdog.observers import Observer
from .monitor_output import start_monitoring, stop_monitoring, watcher_observer, stop_queue_processor
from .encrypt_file import FileEncryptHandler, ENCRYPT_EXTENSIONS, get_encryption_key
from .encrypt_file import stop_queue_processor as stop_encrypt_queue_processor
from .encrypt_file import reset_queue_processor as reset_encrypt_queue_processor
from .safe_paths import resolve_safe_watch_folder
# logging is configured centrally in __init__.py via a FileHandler on
# the root logger. Use a named logger here so records carry the module
# name into drivesend.log.
logger = logging.getLogger(__name__)
NODE_DIR = Path(__file__).parent
encrypt_observer = None
# ---------------------------------------------------------------------------
# Secure persistence for non-credential uploader settings
# ---------------------------------------------------------------------------
# The uploader persists watch_folder, auth_method, and behavior toggles
# to .env so the next session can defaults them in INPUT_TYPES. This is
# unconditional (not gated by COMFYUI_DRIVESEND_ALLOW_SETUP) because
# none of these values are credentials. Race-free 0o600 open keeps the
# file from being world/group-readable in the open->chmod window.
_UPLOADER_KEYS = {
"DRIVESEND_WATCH_FOLDER",
"DRIVESEND_AUTH_METHOD",
"ENABLE_ENCRYPTION",
"POST_DELETE_ENC",
"SUBFOLDER_MONITOR",
"RUN_PROCESS",
}
def _persist_uploader_settings(updates):
"""Merge non-credential uploader settings into .env with 0o600."""
env_path = NODE_DIR / ".env"
existing = {}
if env_path.exists():
existing = dict(dotenv_values(env_path))
merged = dict(existing)
for k, v in updates.items():
merged[k] = str(v)
if merged:
fd = os.open(str(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")
load_dotenv(env_path, override=True)
class DriveSendAutoUploaderNode:
"""ComfyUI node for automatic Google Drive uploads."""
CATEGORY = "DriveSend"
FUNCTION = "start"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
# Default-from-.env so the user does not have to retype settings
# across sessions.
env_path = NODE_DIR / ".env"
cfg = {}
if env_path.exists():
cfg = dict(dotenv_values(env_path))
default_watch = cfg.get("DRIVESEND_WATCH_FOLDER") or os.path.join(os.getcwd(), "output")
default_auth = cfg.get("DRIVESEND_AUTH_METHOD", "oauth")
if default_auth not in ("oauth", "service_account"):
default_auth = "oauth"
default_encrypt = cfg.get("ENABLE_ENCRYPTION", "True").lower() == "true"
default_delete_enc = cfg.get("POST_DELETE_ENC", "False").lower() == "true"
default_subfolder = cfg.get("SUBFOLDER_MONITOR", "True").lower() == "true"
default_run = cfg.get("RUN_PROCESS", "True").lower() == "true"
return {
"required": {
"watch_folder": ("STRING", {"default": default_watch}),
"auth_method": (["oauth", "service_account"], {"default": default_auth}),
"enable_encryption": ("BOOLEAN", {"default": default_encrypt}),
"Post_Delete_Enc": ("BOOLEAN", {"default": default_delete_enc}),
"Subfolder_Monitor": ("BOOLEAN", {"default": default_subfolder}),
"run_process": ("BOOLEAN", {"default": default_run, "label": "Run Process"}),
}
}
def start(self, watch_folder, auth_method, enable_encryption, Post_Delete_Enc,
Subfolder_Monitor, run_process):
"""
Start or stop the Google Drive upload monitor.
When encryption is enabled, two watchers run:
1. Encrypt watcher: creates .enc copies of new images (preserves originals)
2. Upload watcher: uploads .enc files to Google Drive
"""
global encrypt_observer
logger.info(
"Starting DriveSend AutoUploader: watch_folder=%s, auth_method=%s, "
"encryption=%s, Post_Delete_Enc=%s, Subfolder_Monitor=%s, run_process=%s",
watch_folder, auth_method, enable_encryption, Post_Delete_Enc,
Subfolder_Monitor, run_process,
)
# ----- Stop path -----
if not run_process:
logger.info("Stopping DriveSend AutoUploader monitoring")
if watcher_observer and watcher_observer.is_alive():
stop_monitoring()
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_processor()
stop_encrypt_queue_processor()
stop_message = (
"=====================================================================\n"
"🚙🛑 DriveSend - AutoUploader - STOPPED\n"
"=====================================================================\n"
f"All monitoring, uploading, and encryption processes for {watch_folder} have been stopped.\n"
"Set 'run_process' to True and run the node again to resume.\n"
"====================================================================="
)
print(stop_message)
logger.info(stop_message)
return (f"All monitoring stopped for {watch_folder}",)
# ----- Validate watch_folder against the allowed roots -----
try:
watch_folder = resolve_safe_watch_folder(watch_folder)
except ValueError as e:
logger.error(str(e))
return (f"Error: {e}",)
watch_path = Path(watch_folder)
if not watch_path.exists():
logger.error(f"Watch folder does not exist: {watch_folder}")
return (f"Error: Watch folder does not exist: {watch_folder}",)
if not watch_path.is_dir():
logger.error(f"Watch folder is not a directory: {watch_folder}")
return (f"Error: Watch folder is not a directory: {watch_folder}",)
# ----- folder_id required -----
folder_id = os.environ.get("GOOGLE_DRIVE_FOLDER_ID")
if not folder_id:
return (
"Error: GOOGLE_DRIVE_FOLDER_ID not set.\n\n"
"Run the DriveSend Setup node first, then:\n"
" 1. Copy credentials to RunPod Secrets / your secrets manager\n"
" 2. Restart your pod / container so the env vars load",
)
# ----- Auth method requirements -----
if auth_method == "oauth":
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN")
if not all([client_id, client_secret, refresh_token]):
token_file = NODE_DIR / "token.json"
if not token_file.exists():
return (
"Error: OAuth credentials not found.\n\n"
"Set these environment variables:\n"
" - GOOGLE_CLIENT_ID\n"
" - GOOGLE_CLIENT_SECRET\n"
" - GOOGLE_REFRESH_TOKEN\n\n"
"Or run DriveSend Setup to authorize.",
)
elif auth_method == "service_account":
sa_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON")
sa_file = NODE_DIR / "service_account.json"
if not sa_json and not sa_file.exists():
return (
"Error: Service account credentials not found.\n\n"
"Either:\n"
" 1. Set GOOGLE_SERVICE_ACCOUNT_JSON environment variable\n"
" 2. Place service_account.json in the node folder\n\n"
"Note: service accounts only work with Google Workspace.\n"
"For personal Gmail, use OAuth instead.",
)
# ----- Encryption key requirement -----
if enable_encryption:
enc_key = get_encryption_key()
if not enc_key:
logger.error("Encryption enabled but no encryption key found")
return (
"Error: Encryption enabled but no key found.\n\n"
"Set COMFYUI_ENCRYPTION_KEY environment variable\n"
"or run DriveSend Setup with encryption enabled.",
)
# ----- Persist (non-credential) uploader settings -----
_persist_uploader_settings({
"DRIVESEND_WATCH_FOLDER": watch_folder,
"DRIVESEND_AUTH_METHOD": auth_method,
"ENABLE_ENCRYPTION": enable_encryption,
"POST_DELETE_ENC": Post_Delete_Enc,
"SUBFOLDER_MONITOR": Subfolder_Monitor,
"RUN_PROCESS": run_process,
})
# ----- Reset queue processors before starting -----
from .monitor_output import reset_queue_processor as reset_upload_queue
reset_upload_queue()
reset_encrypt_queue_processor()
# ----- Start upload monitor -----
try:
start_monitoring(
watch_folder=watch_folder,
folder_id=folder_id,
auth_method=auth_method,
enable_encryption=enable_encryption,
delete_enc=Post_Delete_Enc,
subfolder_monitor=Subfolder_Monitor,
)
except Exception as e:
logger.error(f"Failed to start upload monitor: {e}")
return (f"Error starting upload monitor: {e}",)
# ----- Start encryption watcher if enabled -----
if enable_encryption:
if encrypt_observer and encrypt_observer.is_alive():
encrypt_observer.stop()
encrypt_observer.join()
encrypt_handler = FileEncryptHandler(watch_folder, False, Subfolder_Monitor)
encrypt_observer = Observer()
encrypt_observer.schedule(encrypt_handler, watch_folder, recursive=Subfolder_Monitor)
encrypt_observer.start()
logger.info(f"Starting encryption monitor for {watch_folder}")
def keep_encrypt_alive():
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
if encrypt_observer:
encrypt_observer.stop()
if encrypt_observer:
encrypt_observer.join()
threading.Thread(target=keep_encrypt_alive, daemon=True).start()
# ----- Status message (no credentials) -----
status_lines = [
"DriveSend AutoUploader started.",
"",
f"Watching: {watch_folder}",
f"Drive folder: {folder_id[:20]}...",
f"Auth method: {auth_method}",
f"Encryption: {'Enabled (creating .enc copies)' if enable_encryption else 'Disabled'}",
f"Subfolder monitoring: {'Enabled' if Subfolder_Monitor else 'Disabled'}",
f"Delete .enc after upload: {'Yes' if Post_Delete_Enc else 'No'}",
]
if enable_encryption:
status_lines.extend([
"",
"Note: original files are preserved for ComfyUI naming.",
"You must manually delete files from the output folder.",
])
status_lines.extend([
"",
"New files will be uploaded automatically.",
"Set run_process to False to stop.",
])
banner = "\n".join(status_lines)
print(banner)
logger.info(banner)
return (banner,)
NODE_CLASS_MAPPINGS = {
"DriveSendAutoUploader": DriveSendAutoUploaderNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DriveSendAutoUploader": "🚙📤 DriveSend - AutoUploader"
}