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

293 lines
13 KiB
Python

"""
Security regression tests.
These tests pin behavior that, if it regresses silently, would
re-introduce a previously-fixed vulnerability. They run without a real
ComfyUI process by stubbing the `server.PromptServer` module.
Run from the repo root:
python -m pytest tests/
Or directly:
python -m unittest tests.test_security
"""
import importlib
import os
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
PARENT_OF_REPO = REPO_ROOT.parent
# Install ghost modules for the google-auth / googleapiclient packages
# BEFORE the package under test imports them. This lets the test run
# without those packages installed in the environment.
def _install_lib_ghosts():
"""Install fake modules for runtime deps so the package can import
without google-auth / googleapiclient / watchdog installed."""
for name in [
"google", "google.oauth2", "google.auth", "google.auth.transport",
"googleapiclient", "google_auth_oauthlib",
"watchdog", "watchdog.observers", "watchdog.events",
]:
if name not in sys.modules:
sys.modules[name] = types.ModuleType(name)
sys.modules["google.oauth2.credentials"] = types.ModuleType("google.oauth2.credentials")
sys.modules["google.oauth2.credentials"].Credentials = mock.MagicMock()
sys.modules["google.oauth2.service_account"] = types.ModuleType("google.oauth2.service_account")
sys.modules["google.oauth2.service_account"].Credentials = mock.MagicMock()
sys.modules["google.auth.transport.requests"] = types.ModuleType("google.auth.transport.requests")
sys.modules["google.auth.transport.requests"].Request = mock.MagicMock()
sys.modules["googleapiclient.discovery"] = types.ModuleType("googleapiclient.discovery")
sys.modules["googleapiclient.discovery"].build = mock.MagicMock()
sys.modules["googleapiclient.http"] = types.ModuleType("googleapiclient.http")
sys.modules["googleapiclient.http"].MediaFileUpload = mock.MagicMock()
sys.modules["googleapiclient.errors"] = types.ModuleType("googleapiclient.errors")
sys.modules["googleapiclient.errors"].HttpError = type("HttpError", (Exception,), {})
sys.modules["google_auth_oauthlib.flow"] = types.ModuleType("google_auth_oauthlib.flow")
sys.modules["google_auth_oauthlib.flow"].Flow = mock.MagicMock()
sys.modules["google_auth_oauthlib.flow"].InstalledAppFlow = mock.MagicMock()
sys.modules["watchdog.observers"].Observer = mock.MagicMock()
sys.modules["watchdog.events"].FileSystemEventHandler = type("FileSystemEventHandler", (object,), {})
_install_lib_ghosts()
def _install_fake_promptserver(client_id, sockets=None):
"""Install a stubbed `server` module so the Setup Node can import it.
Returns a `Mock` for `PromptServer.instance.send_sync` so tests can
assert on calls.
"""
fake_module = types.ModuleType("server")
fake_instance = mock.MagicMock()
fake_instance.client_id = client_id
fake_instance.sockets = sockets if sockets is not None else {}
fake_instance.send_sync = mock.MagicMock()
fake_module.PromptServer = mock.MagicMock()
fake_module.PromptServer.instance = fake_instance
sys.modules["server"] = fake_module
return fake_instance.send_sync
def _install_fake_gdrive_auth_manager(oauth_response=None):
"""Install a fake gdrive_auth_manager module so tests do not require
the google-auth packages to be installed.
"""
fake = types.ModuleType("gdrive_auth_manager")
fake.get_oauth_credentials_for_setup = mock.MagicMock(
return_value=oauth_response or {"credentials": {
"client_id": "stub.apps.googleusercontent.com",
"client_secret": "GOCSPX-stub",
"refresh_token": "1//stub",
"token": "ya29.stub",
"token_uri": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/drive.file"],
}}
)
fake.get_drive_service = mock.MagicMock()
fake.get_folder_id = mock.MagicMock(return_value=None)
fake.get_oauth_credentials = mock.MagicMock(return_value=None)
fake.get_service_account_credentials = mock.MagicMock(return_value=None)
fake.refresh_access_token = mock.MagicMock()
sys.modules["gdrive_auth_manager"] = fake
sys.modules["ComfyUI_DriveSendNode.gdrive_auth_manager"] = fake
return fake
def _import_setup_node_fresh():
"""Re-import the setup node module as part of the ComfyUI_DriveSendNode
package so relative imports (e.g. `from .gdrive_auth_manager import ...`)
resolve correctly.
"""
pkg_name = "ComfyUI_DriveSendNode"
mod_name = f"{pkg_name}.drivesend_setup_node"
# Strip cached entries so import-time route registration sees the
# current `server` mock.
for cached in list(sys.modules):
if cached == mod_name or cached.startswith(mod_name + "."):
sys.modules.pop(cached, None)
sys.modules.pop(mod_name, None)
if str(PARENT_OF_REPO) not in sys.path:
sys.path.insert(0, str(PARENT_OF_REPO))
return importlib.import_module(mod_name)
class TestWebSocketBroadcastGuard(unittest.TestCase):
"""Setup must NEVER call send_sync(..., sid=None) for credential events.
ComfyUI's send_sync with sid=None broadcasts to every connected
WebSocket client. When PromptServer.instance.client_id is None
(typical for curl/SDK submissions that omit client_id), credentials
delivered via display_only would otherwise leak to any concurrent
listener on /ws.
"""
def test_no_send_sync_when_client_id_is_none(self):
send_sync = _install_fake_promptserver(client_id=None)
setup_mod = _import_setup_node_fresh()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(setup_mod, "NODE_DIR", Path(tmp)):
with mock.patch.dict(os.environ, {"COMFYUI_DRIVESEND_ALLOW_SETUP": "1"}, clear=False):
node = setup_mod.DriveSendSetupNode()
# Force a state-changing path: stash an OAuth attempt
# with a usable client_id+secret+auth_code, then call
# setup() with no real client_id present.
setup_mod._stash_set("any-client", {
"google_client_id": "stub.apps.googleusercontent.com",
"google_client_secret": "GOCSPX-stub",
"auth_code": "code-stub",
"service_account_json": "",
})
# The fake PromptServer reports client_id=None, so
# _stash_consume(None) returns nothing. The OAuth
# branch will still proceed but find no inputs and
# return a "click Set credentials..." message
# without touching send_sync.
with mock.patch(
"ComfyUI_DriveSendNode.gdrive_auth_manager.get_oauth_credentials_for_setup",
return_value={"credentials": {
"client_id": "stub.apps.googleusercontent.com",
"client_secret": "GOCSPX-stub",
"refresh_token": "1//stub",
"token": "ya29.stub",
"token_uri": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/drive.file"],
}},
):
node.setup(
auth_method="oauth",
folder_id="stub_folder_id",
storage_method="display_only",
encryption_key_method="off",
reconnect=False,
)
# Assert: every send_sync call was scoped to a non-None sid.
for call in send_sync.call_args_list:
args, kwargs = call
sid = args[2] if len(args) >= 3 else kwargs.get("sid")
self.assertIsNotNone(
sid,
f"send_sync was called with sid=None: {call}. "
"This would broadcast to every connected WebSocket client.",
)
def test_send_sync_called_with_real_sid(self):
"""Sanity check: when a real client_id is present, send_sync IS called
and is scoped to that sid."""
send_sync = _install_fake_promptserver(
client_id="real-sid", sockets={"real-sid": object()}
)
setup_mod = _import_setup_node_fresh()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(setup_mod, "NODE_DIR", Path(tmp)):
with mock.patch.dict(os.environ, {"COMFYUI_DRIVESEND_ALLOW_SETUP": "1"}, clear=False):
setup_mod._stash_set("real-sid", {
"google_client_id": "stub.apps.googleusercontent.com",
"google_client_secret": "GOCSPX-stub",
"auth_code": "code-stub",
"service_account_json": "",
})
with mock.patch(
"ComfyUI_DriveSendNode.gdrive_auth_manager.get_oauth_credentials_for_setup",
return_value={"credentials": {
"client_id": "stub.apps.googleusercontent.com",
"client_secret": "GOCSPX-stub",
"refresh_token": "1//stub",
"token": "ya29.stub",
"token_uri": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/drive.file"],
}},
):
node = setup_mod.DriveSendSetupNode()
node.setup(
auth_method="oauth",
folder_id="stub_folder_id",
storage_method="display_only",
encryption_key_method="off",
reconnect=False,
)
# Expect at least one send_sync delivering the credentials_ready
# event scoped to real-sid.
creds_calls = [
c for c in send_sync.call_args_list
if c.args and c.args[0] == "drivesend_credentials_ready"
]
self.assertTrue(creds_calls, "Expected drivesend_credentials_ready to be sent")
for call in creds_calls:
self.assertEqual(call.args[2], "real-sid")
payload = call.args[1]
self.assertEqual(payload.get("client_id"), "real-sid",
"Payload must echo the sid for JS-side defense in depth")
class TestEnvFilePermissions(unittest.TestCase):
"""The .env file must be created with mode 0o600. Default umask
typically produces 0o644, which is world-readable on multi-tenant
hosts. The Setup and AutoUploader nodes both use os.open with
O_CREAT|0o600 to create the file race-free."""
def test_persist_env_writes_creates_0600(self):
_install_fake_promptserver(client_id=None)
setup_mod = _import_setup_node_fresh()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(setup_mod, "NODE_DIR", Path(tmp)):
env_path = setup_mod._persist_env_writes(
[("GOOGLE_DRIVE_FOLDER_ID", "stub")],
scrub_setup_keys=False,
)
self.assertTrue(env_path.exists())
mode = stat.S_IMODE(env_path.stat().st_mode)
self.assertEqual(
mode, 0o600,
f".env file mode is {oct(mode)}, expected 0o600. "
"Default-umask creation would expose secrets to other local users.",
)
class TestStashHardening(unittest.TestCase):
"""The /drivesend/setup/stash route must enforce: client_id pattern,
payload size cap, JSON content type, live-WebSocket gating. These
are the hardening primitives that prevent stash poisoning."""
def test_client_id_pattern_rejects_bad_input(self):
_install_fake_promptserver(client_id=None)
setup_mod = _import_setup_node_fresh()
good = ["abc", "abc-123", "client.id_42", "X" * 128]
bad = ["", "abc 123", "abc/../etc", "X" * 129, "<script>"]
for s in good:
self.assertIsNotNone(setup_mod._CLIENT_ID_PATTERN.match(s),
f"client_id {s!r} should match pattern but did not")
for s in bad:
self.assertIsNone(setup_mod._CLIENT_ID_PATTERN.match(s),
f"client_id {s!r} should NOT match pattern but did")
def test_stash_ttl_eviction(self):
_install_fake_promptserver(client_id=None)
setup_mod = _import_setup_node_fresh()
# Set a stash entry, then jump time past TTL and confirm it's gone.
setup_mod._stash_set("test-sid", {"google_client_id": "x"})
with mock.patch("time.time", return_value=9_999_999_999):
self.assertIsNone(setup_mod._stash_consume("test-sid"))
if __name__ == "__main__":
unittest.main()