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
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""
|
|
Google Drive Authentication Manager
|
|
Handles OAuth 2.0 and Service Account authentication with auto-refresh support
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import base64
|
|
from pathlib import Path
|
|
|
|
from google.oauth2 import service_account
|
|
from google.oauth2.credentials import Credentials
|
|
from google.auth.transport.requests import Request
|
|
from googleapiclient.discovery import build
|
|
|
|
# Scopes required for Google Drive uploads
|
|
SCOPES = ['https://www.googleapis.com/auth/drive.file']
|
|
|
|
# Path to credential files
|
|
NODE_DIR = Path(__file__).parent
|
|
SERVICE_ACCOUNT_FILE = NODE_DIR / 'service_account.json'
|
|
CLIENT_SECRET_FILE = NODE_DIR / 'client_secret.json'
|
|
TOKEN_FILE = NODE_DIR / 'token.json'
|
|
|
|
|
|
def get_folder_id():
|
|
"""Get the Google Drive folder ID from environment or return None."""
|
|
return os.environ.get('GOOGLE_DRIVE_FOLDER_ID')
|
|
|
|
|
|
def get_service_account_credentials():
|
|
"""
|
|
Get credentials from service account JSON file or environment variable.
|
|
|
|
Returns:
|
|
google.oauth2.service_account.Credentials or None
|
|
"""
|
|
# Try environment variable first (base64 encoded JSON)
|
|
sa_json_b64 = os.environ.get('GOOGLE_SERVICE_ACCOUNT_JSON')
|
|
if sa_json_b64:
|
|
try:
|
|
sa_json = base64.b64decode(sa_json_b64).decode('utf-8')
|
|
sa_info = json.loads(sa_json)
|
|
credentials = service_account.Credentials.from_service_account_info(
|
|
sa_info, scopes=SCOPES
|
|
)
|
|
print("[DriveSend] Using service account from environment variable")
|
|
return credentials
|
|
except Exception as e:
|
|
print(f"[DriveSend] Failed to load service account from env: {e}")
|
|
|
|
# Try file
|
|
if SERVICE_ACCOUNT_FILE.exists():
|
|
try:
|
|
credentials = service_account.Credentials.from_service_account_file(
|
|
str(SERVICE_ACCOUNT_FILE), scopes=SCOPES
|
|
)
|
|
print("[DriveSend] Using service account from file")
|
|
return credentials
|
|
except Exception as e:
|
|
print(f"[DriveSend] Failed to load service account file: {e}")
|
|
|
|
return None
|
|
|
|
|
|
def get_oauth_credentials():
|
|
"""
|
|
Get OAuth 2.0 credentials with automatic refresh.
|
|
|
|
Checks for credentials in this order:
|
|
1. Environment variables (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN)
|
|
2. Saved token.json file
|
|
3. Interactive OAuth flow (requires browser)
|
|
|
|
Returns:
|
|
google.oauth2.credentials.Credentials or None
|
|
"""
|
|
credentials = None
|
|
|
|
# Option 1: Try environment variables (for RunPod)
|
|
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 client_id and client_secret and refresh_token:
|
|
try:
|
|
credentials = Credentials(
|
|
token=None,
|
|
refresh_token=refresh_token,
|
|
token_uri='https://oauth2.googleapis.com/token',
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
scopes=SCOPES
|
|
)
|
|
# Force refresh to get a valid access token
|
|
credentials.refresh(Request())
|
|
print("[DriveSend] OAuth credentials loaded from environment variables")
|
|
print("[DriveSend] ✓ Access token refreshed successfully")
|
|
return credentials
|
|
except Exception as e:
|
|
print(f"[DriveSend] Failed to refresh OAuth token from env: {e}")
|
|
|
|
# Option 2: Try saved token file
|
|
if TOKEN_FILE.exists():
|
|
try:
|
|
credentials = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
|
|
if credentials and credentials.expired and credentials.refresh_token:
|
|
credentials.refresh(Request())
|
|
# Save refreshed token. Race-free 0o600 open so the file
|
|
# is not world-readable in the open->chmod window.
|
|
fd = os.open(str(TOKEN_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, 'w') as token:
|
|
token.write(credentials.to_json())
|
|
print("[DriveSend] OAuth credentials loaded and refreshed from token file")
|
|
elif credentials and credentials.valid:
|
|
print("[DriveSend] OAuth credentials loaded from token file")
|
|
return credentials
|
|
except Exception as e:
|
|
print(f"[DriveSend] Failed to load token file: {e}")
|
|
|
|
# Option 3: Interactive OAuth flow (only works with browser)
|
|
if CLIENT_SECRET_FILE.exists():
|
|
try:
|
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
|
|
print("[DriveSend] Starting OAuth authorization flow...")
|
|
print("[DriveSend] A browser window will open for authorization.")
|
|
|
|
flow = InstalledAppFlow.from_client_secrets_file(
|
|
str(CLIENT_SECRET_FILE), SCOPES
|
|
)
|
|
credentials = flow.run_local_server(port=0)
|
|
|
|
# Save the credentials for future use. Race-free 0o600 open.
|
|
fd = os.open(str(TOKEN_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, 'w') as token:
|
|
token.write(credentials.to_json())
|
|
|
|
print("[DriveSend] ✓ OAuth authorization complete")
|
|
print(f"[DriveSend] Token saved to: {TOKEN_FILE}")
|
|
return credentials
|
|
|
|
except Exception as e:
|
|
print(f"[DriveSend] OAuth flow failed: {e}")
|
|
print("[DriveSend] Note: OAuth flow requires a browser. For headless servers,")
|
|
print("[DriveSend] complete setup locally first and copy credentials to environment variables.")
|
|
|
|
return None
|
|
|
|
|
|
def get_drive_service(auth_method='oauth', **kwargs):
|
|
"""
|
|
Get an authenticated Google Drive service.
|
|
|
|
Args:
|
|
auth_method: 'oauth' or 'service_account'
|
|
**kwargs: Additional arguments (unused, for compatibility)
|
|
|
|
Returns:
|
|
Google Drive API service object
|
|
|
|
Raises:
|
|
Exception if authentication fails
|
|
"""
|
|
credentials = None
|
|
|
|
if auth_method == 'service_account':
|
|
credentials = get_service_account_credentials()
|
|
if not credentials:
|
|
raise Exception(
|
|
"Service account credentials not found. "
|
|
"Place service_account.json in the node folder or set GOOGLE_SERVICE_ACCOUNT_JSON environment variable.\n"
|
|
"NOTE: Service accounts only work with Google Workspace accounts (paid). "
|
|
"For personal Gmail, use OAuth instead."
|
|
)
|
|
|
|
elif auth_method == 'oauth':
|
|
credentials = get_oauth_credentials()
|
|
if not credentials:
|
|
raise Exception(
|
|
"OAuth credentials not found. Options:\n"
|
|
"1. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN environment variables\n"
|
|
"2. Place client_secret.json in the node folder and run setup to authorize\n"
|
|
"3. Run setup locally first if using a headless server"
|
|
)
|
|
|
|
else:
|
|
raise Exception(f"Unknown auth method: {auth_method}. Use 'oauth' or 'service_account'")
|
|
|
|
# Build and return the Drive service
|
|
service = build('drive', 'v3', credentials=credentials)
|
|
return service
|
|
|
|
|
|
def get_oauth_credentials_for_setup(client_id, client_secret, auth_code=None):
|
|
"""
|
|
Get OAuth credentials during setup process.
|
|
|
|
If auth_code is provided, exchanges it for tokens.
|
|
Otherwise, returns the authorization URL.
|
|
|
|
Args:
|
|
client_id: OAuth client ID
|
|
client_secret: OAuth client secret
|
|
auth_code: Authorization code from OAuth flow (optional)
|
|
|
|
Returns:
|
|
dict with either 'auth_url' or 'credentials' containing token info
|
|
"""
|
|
from google_auth_oauthlib.flow import Flow
|
|
|
|
# Create flow from client config
|
|
client_config = {
|
|
"installed": {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"]
|
|
}
|
|
}
|
|
|
|
flow = Flow.from_client_config(client_config, scopes=SCOPES)
|
|
flow.redirect_uri = "urn:ietf:wg:oauth:2.0:oob"
|
|
|
|
if not auth_code:
|
|
# Generate authorization URL
|
|
auth_url, _ = flow.authorization_url(
|
|
access_type='offline',
|
|
include_granted_scopes='true',
|
|
prompt='consent' # Force consent to get refresh token
|
|
)
|
|
return {'auth_url': auth_url}
|
|
|
|
else:
|
|
# Exchange auth code for tokens
|
|
try:
|
|
flow.fetch_token(code=auth_code)
|
|
credentials = flow.credentials
|
|
|
|
return {
|
|
'credentials': {
|
|
'client_id': client_id,
|
|
'client_secret': client_secret,
|
|
'refresh_token': credentials.refresh_token,
|
|
'token': credentials.token,
|
|
'token_uri': credentials.token_uri,
|
|
'scopes': list(credentials.scopes) if credentials.scopes else SCOPES
|
|
}
|
|
}
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
|
|
|
|
def refresh_access_token(client_id, client_secret, refresh_token):
|
|
"""
|
|
Refresh an access token using the refresh token.
|
|
|
|
This is called automatically but can also be used to verify credentials.
|
|
|
|
Args:
|
|
client_id: OAuth client ID
|
|
client_secret: OAuth client secret
|
|
refresh_token: OAuth refresh token
|
|
|
|
Returns:
|
|
dict with 'access_token' or 'error'
|
|
"""
|
|
import requests
|
|
|
|
try:
|
|
response = requests.post(
|
|
'https://oauth2.googleapis.com/token',
|
|
data={
|
|
'client_id': client_id,
|
|
'client_secret': client_secret,
|
|
'refresh_token': refresh_token,
|
|
'grant_type': 'refresh_token'
|
|
}
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return {'access_token': data.get('access_token')}
|
|
else:
|
|
return {'error': f"Token refresh failed: {response.text}"}
|
|
|
|
except Exception as e:
|
|
return {'error': str(e)}
|