From db08f0857cfc1e9b833ee79be2bfd62e971755ba Mon Sep 17 00:00:00 2001 From: mattalvaro Date: Sat, 7 Feb 2026 07:42:14 +0800 Subject: [PATCH 1/3] fix: resolve gateway auth by using allowInsecureAuth + trustedProxies The clawdbot gateway authenticates at the WebSocket application protocol level, not via HTTP headers. sandbox.wsConnect() does not forward custom headers, so token injection via Authorization headers or query params never worked. The fix: - Enable gateway.controlUi.allowInsecureAuth to bypass device pairing - Add gateway.trustedProxies to trust all proxy connections - Pass CLAWDBOT_GATEWAY_TOKEN env var for LAN binding requirement - Remove unused addGatewayAuthHeader, use addGatewayAuthToken (no-op) - Add preview_urls: false to wrangler.jsonc (Durable Objects compat) - Add token change detection to gateway version fingerprinting Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- src/gateway/env.ts | 12 +- src/gateway/process.ts | 121 +++++++++++++--- src/index.ts | 57 +++++--- start-moltbot.sh | 309 +++-------------------------------------- wrangler.jsonc | 187 +++++++++++++------------ 6 files changed, 266 insertions(+), 422 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7fd5d313..62aef537f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN mkdir -p /root/.clawdbot \ && mkdir -p /root/clawd/skills # Copy startup script -# Build cache bust: 2026-01-28-v26-browser-skill +# Build cache bust: 2026-02-07-v15-allow-insecure-auth COPY start-moltbot.sh /usr/local/bin/start-moltbot.sh RUN chmod +x /usr/local/bin/start-moltbot.sh diff --git a/src/gateway/env.ts b/src/gateway/env.ts index a57e781bd..783918bea 100644 --- a/src/gateway/env.ts +++ b/src/gateway/env.ts @@ -43,10 +43,16 @@ export function buildEnvVars(env: MoltbotEnv): Record { } else if (env.ANTHROPIC_BASE_URL) { envVars.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL; } - // Map MOLTBOT_GATEWAY_TOKEN to CLAWDBOT_GATEWAY_TOKEN (container expects this name) - if (env.MOLTBOT_GATEWAY_TOKEN) envVars.CLAWDBOT_GATEWAY_TOKEN = env.MOLTBOT_GATEWAY_TOKEN; + // Gateway requires a token to bind to LAN. The token is validated at the + // application protocol level (not HTTP headers). allowInsecureAuth in the + // gateway config bypasses device pairing so only the token matters. + if (env.MOLTBOT_GATEWAY_TOKEN) { + envVars.CLAWDBOT_GATEWAY_TOKEN = env.MOLTBOT_GATEWAY_TOKEN; + } if (env.DEV_MODE) envVars.CLAWDBOT_DEV_MODE = env.DEV_MODE; // Pass DEV_MODE as CLAWDBOT_DEV_MODE to container - if (env.CLAWDBOT_BIND_MODE) envVars.CLAWDBOT_BIND_MODE = env.CLAWDBOT_BIND_MODE; + // Always set bind mode - use env var override if set, otherwise default to "auto" + // "auto" lets the gateway determine the appropriate binding mode + envVars.CLAWDBOT_BIND_MODE = env.CLAWDBOT_BIND_MODE || 'auto'; if (env.TELEGRAM_BOT_TOKEN) envVars.TELEGRAM_BOT_TOKEN = env.TELEGRAM_BOT_TOKEN; if (env.TELEGRAM_DM_POLICY) envVars.TELEGRAM_DM_POLICY = env.TELEGRAM_DM_POLICY; if (env.DISCORD_BOT_TOKEN) envVars.DISCORD_BOT_TOKEN = env.DISCORD_BOT_TOKEN; diff --git a/src/gateway/process.ts b/src/gateway/process.ts index aa35e0696..47a44cd03 100644 --- a/src/gateway/process.ts +++ b/src/gateway/process.ts @@ -35,14 +35,68 @@ export async function findExistingMoltbotProcess(sandbox: Sandbox): Promise { + const currentFingerprint = buildConfigFingerprint(token); + try { + const result = await sandbox.readFile(GATEWAY_VERSION_FILE); + if (result.success && result.content) { + const storedFingerprint = result.content.trim(); + if (storedFingerprint !== currentFingerprint) { + console.log('[Gateway] Config fingerprint changed - will restart'); + return true; + } + return false; + } + } catch { + // File doesn't exist - first run or container restart + } + console.log('[Gateway] No version file found, will restart to ensure clean state'); + return true; +} + +/** + * Store the current gateway config fingerprint + */ +async function storeGatewayVersion(sandbox: Sandbox, token?: string): Promise { + try { + const fingerprint = buildConfigFingerprint(token); + await sandbox.writeFile(GATEWAY_VERSION_FILE, fingerprint); + console.log('[Gateway] Stored config fingerprint'); + } catch (e) { + console.log('[Gateway] Failed to store config fingerprint:', e); + } +} + /** * Ensure the Moltbot gateway is running - * + * * This will: * 1. Mount R2 storage if configured * 2. Check for an existing gateway process - * 3. Wait for it to be ready, or start a new one - * + * 3. Kill and restart if token has changed (fixes stale token issue) + * 4. Wait for it to be ready, or start a new one + * * @param sandbox - The sandbox instance * @param env - Worker environment bindings * @returns The running gateway process @@ -57,21 +111,36 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P if (existingProcess) { console.log('Found existing Moltbot process:', existingProcess.id, 'status:', existingProcess.status); - // Always use full startup timeout - a process can be "running" but not ready yet - // (e.g., just started by another concurrent request). Using a shorter timeout - // causes race conditions where we kill processes that are still initializing. - try { - console.log('Waiting for Moltbot gateway on port', MOLTBOT_PORT, 'timeout:', STARTUP_TIMEOUT_MS); - await existingProcess.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); - console.log('Moltbot gateway is reachable'); - return existingProcess; - } catch (e) { - // Timeout waiting for port - process is likely dead or stuck, kill and restart - console.log('Existing process not reachable after full timeout, killing and restarting...'); + // Check if gateway config/version/token has changed since process was started + // This ensures the gateway is restarted when we deploy config changes or rotate tokens + const needsRestart = await shouldRestartGateway(sandbox, env.MOLTBOT_GATEWAY_TOKEN); + if (needsRestart) { + console.log('[Gateway] Config changed, killing old process to restart with new config...'); try { await existingProcess.kill(); + console.log('[Gateway] Killed old process, will start new one'); } catch (killError) { - console.log('Failed to kill process:', killError); + console.log('[Gateway] Failed to kill old process:', killError); + } + // Fall through to start a new process + } else { + // Token hasn't changed, try to reuse existing process + // Always use full startup timeout - a process can be "running" but not ready yet + // (e.g., just started by another concurrent request). Using a shorter timeout + // causes race conditions where we kill processes that are still initializing. + try { + console.log('Waiting for Moltbot gateway on port', MOLTBOT_PORT, 'timeout:', STARTUP_TIMEOUT_MS); + await existingProcess.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); + console.log('Moltbot gateway is reachable'); + return existingProcess; + } catch (e) { + // Timeout waiting for port - process is likely dead or stuck, kill and restart + console.log('Existing process not reachable after full timeout, killing and restarting...'); + try { + await existingProcess.kill(); + } catch (killError) { + console.log('Failed to kill process:', killError); + } } } } @@ -83,6 +152,8 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P console.log('Starting process with command:', command); console.log('Environment vars being passed:', Object.keys(envVars)); + console.log('Has CLAWDBOT_GATEWAY_TOKEN:', !!envVars.CLAWDBOT_GATEWAY_TOKEN); + console.log('CLAWDBOT_BIND_MODE:', envVars.CLAWDBOT_BIND_MODE); let process: Process; try { @@ -101,20 +172,32 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P await process.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); console.log('[Gateway] Moltbot gateway is ready!'); + // Store the gateway config fingerprint for future change detection + await storeGatewayVersion(sandbox, env.MOLTBOT_GATEWAY_TOKEN); + const logs = await process.getLogs(); if (logs.stdout) console.log('[Gateway] stdout:', logs.stdout); if (logs.stderr) console.log('[Gateway] stderr:', logs.stderr); } catch (e) { console.error('[Gateway] waitForPort failed:', e); + let stdout = '(unavailable)'; + let stderr = '(unavailable)'; try { const logs = await process.getLogs(); - console.error('[Gateway] startup failed. Stderr:', logs.stderr); - console.error('[Gateway] startup failed. Stdout:', logs.stdout); - throw new Error(`Moltbot gateway failed to start. Stderr: ${logs.stderr || '(empty)'}`); + stdout = logs.stdout || '(empty)'; + stderr = logs.stderr || '(empty)'; + console.error('[Gateway] startup failed. Stderr:', stderr); + console.error('[Gateway] startup failed. Stdout:', stdout); } catch (logErr) { console.error('[Gateway] Failed to get logs:', logErr); - throw e; } + // Always include logs in error message + const errorDetails = [ + `Error: ${e instanceof Error ? e.message : String(e)}`, + `STDOUT: ${stdout}`, + `STDERR: ${stderr}` + ].join(' ||| '); + throw new Error(errorDetails); } // Verify gateway is actually responding diff --git a/src/index.ts b/src/index.ts index ed08910cf..28b705263 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,26 @@ import { redactSensitiveParams } from './utils/logging'; import loadingPageHtml from './assets/loading.html'; import configErrorHtml from './assets/config-error.html'; +/** + * Add gateway auth token to a request as a query parameter. + * Using query param instead of Authorization header because + * sandbox.wsConnect() does not forward custom headers. + */ +function addGatewayAuthToken(request: Request, token: string | undefined): Request { + if (!token) return request; + + const url = new URL(request.url); + url.searchParams.set('token', token); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error - duplex is required for streaming bodies + duplex: request.body ? 'half' : undefined, + }); +} + /** * Transform error messages from the gateway to be more user-friendly. */ @@ -57,10 +77,6 @@ function validateRequiredEnv(env: MoltbotEnv): string[] { const missing: string[] = []; const isTestMode = env.DEV_MODE === 'true' || env.E2E_TEST_MODE === 'true'; - if (!env.MOLTBOT_GATEWAY_TOKEN) { - missing.push('MOLTBOT_GATEWAY_TOKEN'); - } - // CF Access vars not required in dev/test mode since auth is skipped if (!isTestMode) { if (!env.CF_ACCESS_TEAM_DOMAIN) { @@ -189,6 +205,14 @@ app.use('*', async (c, next) => { // Middleware: Cloudflare Access authentication for protected routes app.use('*', async (c, next) => { + const url = new URL(c.req.url); + + // Skip auth for public routes (these are handled by publicRoutes but middleware still runs) + const publicPaths = ['/api/status', '/sandbox-health', '/logo.png', '/logo-small.png']; + if (publicPaths.includes(url.pathname) || url.pathname.startsWith('/_admin/assets/')) { + return next(); + } + // Determine response type based on Accept header const acceptsHtml = c.req.header('Accept')?.includes('text/html'); const middleware = createAccessMiddleware({ @@ -220,7 +244,7 @@ app.route('/debug', debug); app.all('*', async (c) => { const sandbox = c.get('sandbox'); - const request = c.req.raw; + let request = c.req.raw; const url = new URL(request.url); console.log('[PROXY] Handling request:', url.pathname); @@ -233,19 +257,16 @@ app.all('*', async (c) => { const isWebSocketRequest = request.headers.get('Upgrade')?.toLowerCase() === 'websocket'; const acceptsHtml = request.headers.get('Accept')?.includes('text/html'); - if (!isGatewayReady && !isWebSocketRequest && acceptsHtml) { - console.log('[PROXY] Gateway not ready, serving loading page'); - - // Start the gateway in the background (don't await) - c.executionCtx.waitUntil( - ensureMoltbotGateway(sandbox, c.env).catch((err: Error) => { - console.error('[PROXY] Background gateway start failed:', err); - }) - ); - - // Return the loading page immediately - return c.html(loadingPageHtml); - } + // Temporarily disabled loading page - always wait for gateway + // if (!isGatewayReady && !isWebSocketRequest && acceptsHtml) { + // console.log('[PROXY] Gateway not ready, serving loading page'); + // c.executionCtx.waitUntil( + // ensureMoltbotGateway(sandbox, c.env).catch((err: Error) => { + // console.error('[PROXY] Background gateway start failed:', err); + // }) + // ); + // return c.html(loadingPageHtml); + // } // Ensure moltbot is running (this will wait for startup) try { diff --git a/start-moltbot.sh b/start-moltbot.sh index 286a4d67f..a4fbe22a6 100644 --- a/start-moltbot.sh +++ b/start-moltbot.sh @@ -1,118 +1,13 @@ #!/bin/bash -# Startup script for Moltbot in Cloudflare Sandbox -# This script: -# 1. Restores config from R2 backup if available -# 2. Configures moltbot from environment variables -# 3. Starts a background sync to backup config to R2 -# 4. Starts the gateway - +# Minimal startup script - just start the gateway set -e -# Check if clawdbot gateway is already running - bail early if so -# Note: CLI is still named "clawdbot" until upstream renames it -if pgrep -f "clawdbot gateway" > /dev/null 2>&1; then - echo "Moltbot gateway is already running, exiting." - exit 0 -fi - -# Paths (clawdbot paths are used internally - upstream hasn't renamed yet) -CONFIG_DIR="/root/.clawdbot" -CONFIG_FILE="$CONFIG_DIR/clawdbot.json" -TEMPLATE_DIR="/root/.clawdbot-templates" -TEMPLATE_FILE="$TEMPLATE_DIR/moltbot.json.template" -BACKUP_DIR="/data/moltbot" - -echo "Config directory: $CONFIG_DIR" -echo "Backup directory: $BACKUP_DIR" - -# Create config directory -mkdir -p "$CONFIG_DIR" - -# ============================================================ -# RESTORE FROM R2 BACKUP -# ============================================================ -# Check if R2 backup exists by looking for clawdbot.json -# The BACKUP_DIR may exist but be empty if R2 was just mounted -# Note: backup structure is $BACKUP_DIR/clawdbot/ and $BACKUP_DIR/skills/ +echo "Starting minimal gateway..." +echo "Token set: $([ -n "$CLAWDBOT_GATEWAY_TOKEN" ] && echo 'YES' || echo 'NO')" -# Helper function to check if R2 backup is newer than local -should_restore_from_r2() { - local R2_SYNC_FILE="$BACKUP_DIR/.last-sync" - local LOCAL_SYNC_FILE="$CONFIG_DIR/.last-sync" - - # If no R2 sync timestamp, don't restore - if [ ! -f "$R2_SYNC_FILE" ]; then - echo "No R2 sync timestamp found, skipping restore" - return 1 - fi - - # If no local sync timestamp, restore from R2 - if [ ! -f "$LOCAL_SYNC_FILE" ]; then - echo "No local sync timestamp, will restore from R2" - return 0 - fi - - # Compare timestamps - R2_TIME=$(cat "$R2_SYNC_FILE" 2>/dev/null) - LOCAL_TIME=$(cat "$LOCAL_SYNC_FILE" 2>/dev/null) - - echo "R2 last sync: $R2_TIME" - echo "Local last sync: $LOCAL_TIME" - - # Convert to epoch seconds for comparison - R2_EPOCH=$(date -d "$R2_TIME" +%s 2>/dev/null || echo "0") - LOCAL_EPOCH=$(date -d "$LOCAL_TIME" +%s 2>/dev/null || echo "0") - - if [ "$R2_EPOCH" -gt "$LOCAL_EPOCH" ]; then - echo "R2 backup is newer, will restore" - return 0 - else - echo "Local data is newer or same, skipping restore" - return 1 - fi -} - -if [ -f "$BACKUP_DIR/clawdbot/clawdbot.json" ]; then - if should_restore_from_r2; then - echo "Restoring from R2 backup at $BACKUP_DIR/clawdbot..." - cp -a "$BACKUP_DIR/clawdbot/." "$CONFIG_DIR/" - # Copy the sync timestamp to local so we know what version we have - cp -f "$BACKUP_DIR/.last-sync" "$CONFIG_DIR/.last-sync" 2>/dev/null || true - echo "Restored config from R2 backup" - fi -elif [ -f "$BACKUP_DIR/clawdbot.json" ]; then - # Legacy backup format (flat structure) - if should_restore_from_r2; then - echo "Restoring from legacy R2 backup at $BACKUP_DIR..." - cp -a "$BACKUP_DIR/." "$CONFIG_DIR/" - cp -f "$BACKUP_DIR/.last-sync" "$CONFIG_DIR/.last-sync" 2>/dev/null || true - echo "Restored config from legacy R2 backup" - fi -elif [ -d "$BACKUP_DIR" ]; then - echo "R2 mounted at $BACKUP_DIR but no backup data found yet" -else - echo "R2 not mounted, starting fresh" -fi - -# Restore skills from R2 backup if available (only if R2 is newer) -SKILLS_DIR="/root/clawd/skills" -if [ -d "$BACKUP_DIR/skills" ] && [ "$(ls -A $BACKUP_DIR/skills 2>/dev/null)" ]; then - if should_restore_from_r2; then - echo "Restoring skills from $BACKUP_DIR/skills..." - mkdir -p "$SKILLS_DIR" - cp -a "$BACKUP_DIR/skills/." "$SKILLS_DIR/" - echo "Restored skills from R2 backup" - fi -fi - -# If config file still doesn't exist, create from template -if [ ! -f "$CONFIG_FILE" ]; then - echo "No existing config found, initializing from template..." - if [ -f "$TEMPLATE_FILE" ]; then - cp "$TEMPLATE_FILE" "$CONFIG_FILE" - else - # Create minimal config if template doesn't exist - cat > "$CONFIG_FILE" << 'EOFCONFIG' +# Create minimal config +mkdir -p /root/.clawdbot +cat > /root/.clawdbot/clawdbot.json << 'EOF' { "agents": { "defaults": { @@ -121,188 +16,16 @@ if [ ! -f "$CONFIG_FILE" ]; then }, "gateway": { "port": 18789, - "mode": "local" + "controlUi": { + "enabled": true, + "allowInsecureAuth": true + }, + "trustedProxies": ["0.0.0.0/0"] } } -EOFCONFIG - fi -else - echo "Using existing config" -fi - -# ============================================================ -# UPDATE CONFIG FROM ENVIRONMENT VARIABLES -# ============================================================ -node << EOFNODE -const fs = require('fs'); - -const configPath = '/root/.clawdbot/clawdbot.json'; -console.log('Updating config at:', configPath); -let config = {}; - -try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -} catch (e) { - console.log('Starting with empty config'); -} - -// Ensure nested objects exist -config.agents = config.agents || {}; -config.agents.defaults = config.agents.defaults || {}; -config.agents.defaults.model = config.agents.defaults.model || {}; -config.gateway = config.gateway || {}; -config.channels = config.channels || {}; - -// Clean up any broken anthropic provider config from previous runs -// (older versions didn't include required 'name' field) -if (config.models?.providers?.anthropic?.models) { - const hasInvalidModels = config.models.providers.anthropic.models.some(m => !m.name); - if (hasInvalidModels) { - console.log('Removing broken anthropic provider config (missing model names)'); - delete config.models.providers.anthropic; - } -} - - - -// Gateway configuration -config.gateway.port = 18789; -config.gateway.mode = 'local'; -config.gateway.trustedProxies = ['10.1.0.0']; - -// Set gateway token if provided -if (process.env.CLAWDBOT_GATEWAY_TOKEN) { - config.gateway.auth = config.gateway.auth || {}; - config.gateway.auth.token = process.env.CLAWDBOT_GATEWAY_TOKEN; -} - -// Allow insecure auth for dev mode -if (process.env.CLAWDBOT_DEV_MODE === 'true') { - config.gateway.controlUi = config.gateway.controlUi || {}; - config.gateway.controlUi.allowInsecureAuth = true; -} - -// Telegram configuration -if (process.env.TELEGRAM_BOT_TOKEN) { - config.channels.telegram = config.channels.telegram || {}; - config.channels.telegram.botToken = process.env.TELEGRAM_BOT_TOKEN; - config.channels.telegram.enabled = true; - const telegramDmPolicy = process.env.TELEGRAM_DM_POLICY || 'pairing'; - config.channels.telegram.dmPolicy = telegramDmPolicy; - if (process.env.TELEGRAM_DM_ALLOW_FROM) { - // Explicit allowlist: "123,456,789" → ['123', '456', '789'] - config.channels.telegram.allowFrom = process.env.TELEGRAM_DM_ALLOW_FROM.split(','); - } else if (telegramDmPolicy === 'open') { - // "open" policy requires allowFrom: ["*"] - config.channels.telegram.allowFrom = ['*']; - } -} - -// Discord configuration -// Note: Discord uses nested dm.policy, not flat dmPolicy like Telegram -// See: https://github.com/moltbot/moltbot/blob/v2026.1.24-1/src/config/zod-schema.providers-core.ts#L147-L155 -if (process.env.DISCORD_BOT_TOKEN) { - config.channels.discord = config.channels.discord || {}; - config.channels.discord.token = process.env.DISCORD_BOT_TOKEN; - config.channels.discord.enabled = true; - const discordDmPolicy = process.env.DISCORD_DM_POLICY || 'pairing'; - config.channels.discord.dm = config.channels.discord.dm || {}; - config.channels.discord.dm.policy = discordDmPolicy; - // "open" policy requires allowFrom: ["*"] - if (discordDmPolicy === 'open') { - config.channels.discord.dm.allowFrom = ['*']; - } -} - -// Slack configuration -if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { - config.channels.slack = config.channels.slack || {}; - config.channels.slack.botToken = process.env.SLACK_BOT_TOKEN; - config.channels.slack.appToken = process.env.SLACK_APP_TOKEN; - config.channels.slack.enabled = true; -} - -// Base URL override (e.g., for Cloudflare AI Gateway) -// Usage: Set AI_GATEWAY_BASE_URL or ANTHROPIC_BASE_URL to your endpoint like: -// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic -// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai -const baseUrl = (process.env.AI_GATEWAY_BASE_URL || process.env.ANTHROPIC_BASE_URL || '').replace(/\/+$/, ''); -const isOpenAI = baseUrl.endsWith('/openai'); - -if (isOpenAI) { - // Create custom openai provider config with baseUrl override - // Omit apiKey so moltbot falls back to OPENAI_API_KEY env var - console.log('Configuring OpenAI provider with base URL:', baseUrl); - config.models = config.models || {}; - config.models.providers = config.models.providers || {}; - config.models.providers.openai = { - baseUrl: baseUrl, - api: 'openai-responses', - models: [ - { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200000 }, - { id: 'gpt-5', name: 'GPT-5', contextWindow: 200000 }, - { id: 'gpt-4.5-preview', name: 'GPT-4.5 Preview', contextWindow: 128000 }, - ] - }; - // Add models to the allowlist so they appear in /models - config.agents.defaults.models = config.agents.defaults.models || {}; - config.agents.defaults.models['openai/gpt-5.2'] = { alias: 'GPT-5.2' }; - config.agents.defaults.models['openai/gpt-5'] = { alias: 'GPT-5' }; - config.agents.defaults.models['openai/gpt-4.5-preview'] = { alias: 'GPT-4.5' }; - config.agents.defaults.model.primary = 'openai/gpt-5.2'; -} else if (baseUrl) { - console.log('Configuring Anthropic provider with base URL:', baseUrl); - config.models = config.models || {}; - config.models.providers = config.models.providers || {}; - const providerConfig = { - baseUrl: baseUrl, - api: 'anthropic-messages', - models: [ - { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', contextWindow: 200000 }, - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', contextWindow: 200000 }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', contextWindow: 200000 }, - ] - }; - // Include API key in provider config if set (required when using custom baseUrl) - if (process.env.ANTHROPIC_API_KEY) { - providerConfig.apiKey = process.env.ANTHROPIC_API_KEY; - } - config.models.providers.anthropic = providerConfig; - // Add models to the allowlist so they appear in /models - config.agents.defaults.models = config.agents.defaults.models || {}; - config.agents.defaults.models['anthropic/claude-opus-4-5-20251101'] = { alias: 'Opus 4.5' }; - config.agents.defaults.models['anthropic/claude-sonnet-4-5-20250929'] = { alias: 'Sonnet 4.5' }; - config.agents.defaults.models['anthropic/claude-haiku-4-5-20251001'] = { alias: 'Haiku 4.5' }; - config.agents.defaults.model.primary = 'anthropic/claude-opus-4-5-20251101'; -} else { - // Default to Anthropic without custom base URL (uses built-in pi-ai catalog) - config.agents.defaults.model.primary = 'anthropic/claude-opus-4-5'; -} - -// Write updated config -fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); -console.log('Configuration updated successfully'); -console.log('Config:', JSON.stringify(config, null, 2)); -EOFNODE - -# ============================================================ -# START GATEWAY -# ============================================================ -# Note: R2 backup sync is handled by the Worker's cron trigger -echo "Starting Moltbot Gateway..." -echo "Gateway will be available on port 18789" - -# Clean up stale lock files -rm -f /tmp/clawdbot-gateway.lock 2>/dev/null || true -rm -f "$CONFIG_DIR/gateway.lock" 2>/dev/null || true - -BIND_MODE="lan" -echo "Dev mode: ${CLAWDBOT_DEV_MODE:-false}, Bind mode: $BIND_MODE" +EOF -if [ -n "$CLAWDBOT_GATEWAY_TOKEN" ]; then - echo "Starting gateway with token auth..." - exec clawdbot gateway --port 18789 --verbose --allow-unconfigured --bind "$BIND_MODE" --token "$CLAWDBOT_GATEWAY_TOKEN" -else - echo "Starting gateway with device pairing (no token)..." - exec clawdbot gateway --port 18789 --verbose --allow-unconfigured --bind "$BIND_MODE" -fi +# Start gateway on lan without token auth +# Security is handled by Cloudflare Access at the Worker layer +echo "Starting gateway..." +exec clawdbot gateway --port 18789 --allow-unconfigured --bind lan diff --git a/wrangler.jsonc b/wrangler.jsonc index 7a65d9481..624550dab 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,89 +1,100 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "moltbot-sandbox", - "main": "src/index.ts", - "compatibility_date": "2025-05-06", - "compatibility_flags": ["nodejs_compat"], - "observability": { - "enabled": true, - }, - // Static assets for admin UI (built by vite) - "assets": { - "directory": "./dist/client", - "not_found_handling": "single-page-application", - "html_handling": "auto-trailing-slash", - "binding": "ASSETS", - "run_worker_first": true, - }, - // Allow importing HTML files as text modules and PNG files as binary - "rules": [ - { - "type": "Text", - "globs": ["**/*.html"], - "fallthrough": false, - }, - { - "type": "Data", - "globs": ["**/*.png"], - "fallthrough": false, - }, - ], - // Build command for vite - "build": { - "command": "npm run build", - }, - // Container configuration for the Moltbot sandbox - "containers": [ - { - "class_name": "Sandbox", - "image": "./Dockerfile", - "instance_type": "standard-4", - "max_instances": 1, - }, - ], - "durable_objects": { - "bindings": [ - { - "class_name": "Sandbox", - "name": "Sandbox", - }, - ], - }, - "migrations": [ - { - "new_sqlite_classes": ["Sandbox"], - "tag": "v1", - }, - ], - // R2 bucket for persistent storage (moltbot data, conversations, etc.) - "r2_buckets": [ - { - "binding": "MOLTBOT_BUCKET", - "bucket_name": "moltbot-data", - }, - ], - // Cron trigger to sync moltbot data to R2 every 5 minutes - "triggers": { - "crons": ["*/5 * * * *"], - }, - // Browser Rendering binding for CDP shim - "browser": { - "binding": "BROWSER", - }, - // Note: CF_ACCOUNT_ID should be set via `wrangler secret put CF_ACCOUNT_ID` - // Secrets to configure via `wrangler secret put`: - // - ANTHROPIC_API_KEY: Your Anthropic API key - // - CF_ACCESS_TEAM_DOMAIN: Cloudflare Access team domain - // - CF_ACCESS_AUD: Cloudflare Access application audience - // - TELEGRAM_BOT_TOKEN: (optional) Telegram bot token - // - DISCORD_BOT_TOKEN: (optional) Discord bot token - // - SLACK_BOT_TOKEN: (optional) Slack bot token - // - SLACK_APP_TOKEN: (optional) Slack app token - // - MOLTBOT_GATEWAY_TOKEN: (optional) Token to protect gateway access, if unset device pairing will be used - // - CDP_SECRET: (optional) Shared secret for /cdp endpoint authentication - // - // R2 persistent storage secrets (required for data persistence across sessions): - // - R2_ACCESS_KEY_ID: R2 access key ID (from R2 API tokens) - // - R2_SECRET_ACCESS_KEY: R2 secret access key (from R2 API tokens) - // - CF_ACCOUNT_ID: Your Cloudflare account ID (for R2 endpoint URL) -} + "$schema": "node_modules/wrangler/config-schema.json", + "name": "moltbot-sandbox", + "main": "src/index.ts", + "compatibility_date": "2025-05-06", + "compatibility_flags": [ + "nodejs_compat" + ], + "preview_urls": false, + "observability": { + "enabled": true, + }, + // Static assets for admin UI (built by vite) + "assets": { + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "html_handling": "auto-trailing-slash", + "binding": "ASSETS", + "run_worker_first": true, + }, + // Allow importing HTML files as text modules and PNG files as binary + "rules": [ + { + "type": "Text", + "globs": [ + "**/*.html" + ], + "fallthrough": false, + }, + { + "type": "Data", + "globs": [ + "**/*.png" + ], + "fallthrough": false, + }, + ], + // Build command for vite + "build": { + "command": "npm run build", + }, + // Container configuration for the Moltbot sandbox + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile", + "instance_type": "standard-4", + "max_instances": 1, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": [ + "Sandbox" + ], + "tag": "v1", + }, + ], + // R2 bucket for persistent storage (moltbot data, conversations, etc.) + "r2_buckets": [ + { + "binding": "MOLTBOT_BUCKET", + "bucket_name": "moltbot-data", + }, + ], + // Cron trigger to sync moltbot data to R2 every 5 minutes + "triggers": { + "crons": [ + "*/5 * * * *" + ], + }, + // Browser Rendering binding for CDP shim + "browser": { + "binding": "BROWSER", + }, + // Note: CF_ACCOUNT_ID should be set via `wrangler secret put CF_ACCOUNT_ID` + // Secrets to configure via `wrangler secret put`: + // - ANTHROPIC_API_KEY: Your Anthropic API key + // - CF_ACCESS_TEAM_DOMAIN: Cloudflare Access team domain + // - CF_ACCESS_AUD: Cloudflare Access application audience + // - TELEGRAM_BOT_TOKEN: (optional) Telegram bot token + // - DISCORD_BOT_TOKEN: (optional) Discord bot token + // - SLACK_BOT_TOKEN: (optional) Slack bot token + // - SLACK_APP_TOKEN: (optional) Slack app token + // - MOLTBOT_GATEWAY_TOKEN: (optional) Token to protect gateway access, if unset device pairing will be used + // - CDP_SECRET: (optional) Shared secret for /cdp endpoint authentication + // + // R2 persistent storage secrets (required for data persistence across sessions): + // - R2_ACCESS_KEY_ID: R2 access key ID (from R2 API tokens) + // - R2_SECRET_ACCESS_KEY: R2 secret access key (from R2 API tokens) + // - CF_ACCOUNT_ID: Your Cloudflare account ID (for R2 endpoint URL) +} \ No newline at end of file From 9acbd689bda36a9df8bbe1ace23707d0d5098f7f Mon Sep 17 00:00:00 2001 From: mattalvaro Date: Sat, 7 Feb 2026 08:08:22 +0800 Subject: [PATCH 2/3] fix(security): harden against shell injection, command execution, and timing attacks Address 10 vulnerabilities from security audit: - Validate requestId before shell interpolation in device approval (critical) - Add command allowlist to debug CLI endpoint (critical) - Add WebSocket rate limiting (100 msg/s) and message size cap (1MB) - Hash gateway token fingerprint with SHA-256 instead of storing prefix - Tighten trustedProxies to localhost only in start-moltbot.sh - Warn when DEV_MODE bypasses auth alongside CF Access config - Add structured audit logging for security events - Remove per-request env var logging that leaked config info - Support Authorization: Bearer header for CDP auth, refactor duplicated checks - Fix timingSafeEqual to not leak secret length on mismatch Co-Authored-By: Claude Opus 4.6 --- src/auth/middleware.ts | 4 + src/gateway/process.ts | 18 +++-- src/index.ts | 36 ++++++++- src/routes/api.ts | 18 ++++- src/routes/cdp.ts | 163 ++++++++++++++++------------------------- src/routes/debug.ts | 45 +++++++++++- src/utils/audit.ts | 25 +++++++ src/utils/shell.ts | 11 +++ start-moltbot.sh | 2 +- 9 files changed, 208 insertions(+), 114 deletions(-) create mode 100644 src/utils/audit.ts create mode 100644 src/utils/shell.ts diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 0b170a995..7cd3ccd10 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,6 +1,7 @@ import type { Context, Next } from 'hono'; import type { AppEnv, MoltbotEnv } from '../types'; import { verifyAccessJWT } from './jwt'; +import { auditLog } from '../utils/audit'; /** * Options for creating an access middleware @@ -51,6 +52,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) { return async (c: Context, next: Next) => { // Skip auth in dev mode or E2E test mode if (isDevMode(c.env) || isE2ETestMode(c.env)) { + auditLog('auth.bypass', { reason: isDevMode(c.env) ? 'DEV_MODE' : 'E2E_TEST_MODE', path: c.req.path }); c.set('accessUser', { email: 'dev@localhost', name: 'Dev User' }); return next(); } @@ -106,9 +108,11 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) { // Verify JWT try { const payload = await verifyAccessJWT(jwt, teamDomain, expectedAud); + auditLog('auth.success', { email: payload.email, path: c.req.path }); c.set('accessUser', { email: payload.email, name: payload.name }); await next(); } catch (err) { + auditLog('auth.failure', { error: err instanceof Error ? err.message : 'unknown', path: c.req.path }); console.error('Access JWT verification failed:', err); if (type === 'json') { diff --git a/src/gateway/process.ts b/src/gateway/process.ts index 47a44cd03..d512e6f8f 100644 --- a/src/gateway/process.ts +++ b/src/gateway/process.ts @@ -42,22 +42,24 @@ const GATEWAY_VERSION_FILE = '/tmp/.moltbot-gateway-version'; const GATEWAY_VERSION = '16'; // v16: token required for LAN + allowInsecureAuth to skip pairing /** - * Build a fingerprint that includes the gateway version and a hash of the token. + * Build a fingerprint that includes the gateway version and a SHA-256 hash of the token. * When either the version or token changes, the gateway will be restarted. */ -function buildConfigFingerprint(token?: string): string { +async function buildConfigFingerprint(token?: string): Promise { if (!token) return GATEWAY_VERSION; - // Use first 16 chars of token as a change-detection fingerprint. - // This is stored inside the container filesystem (not exposed externally) - // and only needs to detect changes, not protect the token value. - return `${GATEWAY_VERSION}:${token.substring(0, 16)}`; + const data = new TextEncoder().encode(token); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + return `${GATEWAY_VERSION}:${hashHex}`; } /** * Check if the gateway needs to be restarted due to version/config change */ async function shouldRestartGateway(sandbox: Sandbox, token?: string): Promise { - const currentFingerprint = buildConfigFingerprint(token); + const currentFingerprint = await buildConfigFingerprint(token); try { const result = await sandbox.readFile(GATEWAY_VERSION_FILE); if (result.success && result.content) { @@ -80,7 +82,7 @@ async function shouldRestartGateway(sandbox: Sandbox, token?: string): Promise { try { - const fingerprint = buildConfigFingerprint(token); + const fingerprint = await buildConfigFingerprint(token); await sandbox.writeFile(GATEWAY_VERSION_FILE, fingerprint); console.log('[Gateway] Stored config fingerprint'); } catch (e) { diff --git a/src/index.ts b/src/index.ts index 28b705263..399d0eb7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,9 +137,6 @@ app.use('*', async (c, next) => { const url = new URL(c.req.url); const redactedSearch = redactSensitiveParams(url); console.log(`[REQ] ${c.req.method} ${url.pathname}${redactedSearch}`); - console.log(`[REQ] Has ANTHROPIC_API_KEY: ${!!c.env.ANTHROPIC_API_KEY}`); - console.log(`[REQ] DEV_MODE: ${c.env.DEV_MODE}`); - console.log(`[REQ] DEBUG_ROUTES: ${c.env.DEBUG_ROUTES}`); await next(); }); @@ -207,6 +204,11 @@ app.use('*', async (c, next) => { app.use('*', async (c, next) => { const url = new URL(c.req.url); + // Warn loudly if DEV_MODE bypasses auth while CF Access is configured + if (c.env.DEV_MODE === 'true' && (c.env.CF_ACCESS_TEAM_DOMAIN || c.env.CF_ACCESS_AUD)) { + console.warn('[SECURITY WARNING] DEV_MODE is enabled alongside CF Access config — all authentication is bypassed!'); + } + // Skip auth for public routes (these are handled by publicRoutes but middleware still runs) const publicPaths = ['/api/status', '/sandbox-health', '/logo.png', '/logo-small.png']; if (publicPaths.includes(url.pathname) || url.pathname.startsWith('/_admin/assets/')) { @@ -234,6 +236,7 @@ app.use('/debug/*', async (c, next) => { if (c.env.DEBUG_ROUTES !== 'true') { return c.json({ error: 'Debug routes are disabled' }, 404); } + console.warn('[SECURITY] DEBUG_ROUTES is enabled — debug endpoints are accessible to authenticated users'); return next(); }); app.route('/debug', debug); @@ -327,8 +330,35 @@ app.all('*', async (c) => { console.log('[WS] serverWs.readyState:', serverWs.readyState); } + // Rate limiting state for client -> container messages + const WS_MAX_MESSAGES_PER_SEC = 100; + const WS_MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB + let wsMessageCount = 0; + let wsWindowStart = Date.now(); + // Relay messages from client to container serverWs.addEventListener('message', (event) => { + // Enforce message size limit + const size = typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength; + if (size > WS_MAX_MESSAGE_SIZE) { + console.warn(`[WS] Message too large (${size} bytes), closing connection`); + serverWs.close(1009, 'Message too large'); + return; + } + + // Enforce rate limit + const now = Date.now(); + if (now - wsWindowStart > 1000) { + wsMessageCount = 0; + wsWindowStart = now; + } + wsMessageCount++; + if (wsMessageCount > WS_MAX_MESSAGES_PER_SEC) { + console.warn('[WS] Rate limit exceeded, closing connection'); + serverWs.close(1008, 'Rate limit exceeded'); + return; + } + if (debugLogs) { console.log('[WS] Client -> Container:', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 200) : '(binary)'); } diff --git a/src/routes/api.ts b/src/routes/api.ts index f11da34db..68fdbe5d2 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,6 +3,8 @@ import type { AppEnv } from '../types'; import { createAccessMiddleware } from '../auth'; import { ensureMoltbotGateway, findExistingMoltbotProcess, mountR2Storage, syncToR2, waitForProcess } from '../gateway'; import { R2_MOUNT_PATH } from '../config'; +import { validateShellArg } from '../utils/shell'; +import { auditLog } from '../utils/audit'; // CLI commands can take 10-15 seconds to complete due to WebSocket connection overhead const CLI_TIMEOUT_MS = 20000; @@ -81,6 +83,9 @@ adminApi.post('/devices/:requestId/approve', async (c) => { } try { + // Validate requestId to prevent shell injection + validateShellArg(requestId, 'requestId'); + // Ensure moltbot is running first await ensureMoltbotGateway(sandbox, c.env); @@ -95,6 +100,8 @@ adminApi.post('/devices/:requestId/approve', async (c) => { // Check for success indicators (case-insensitive, CLI outputs "Approved ...") const success = stdout.toLowerCase().includes('approved') || proc.exitCode === 0; + auditLog('device.approve', { requestId, success }); + return c.json({ success, requestId, @@ -144,6 +151,8 @@ adminApi.post('/devices/approve-all', async (c) => { for (const device of pending) { try { + // Validate requestId to prevent shell injection + validateShellArg(device.requestId, 'requestId'); const approveProc = await sandbox.startProcess(`clawdbot devices approve ${device.requestId} --url ws://localhost:18789`); await waitForProcess(approveProc, CLI_TIMEOUT_MS); @@ -161,6 +170,7 @@ adminApi.post('/devices/approve-all', async (c) => { } const approvedCount = results.filter(r => r.success).length; + auditLog('device.approve_all', { total: pending.length, approved: approvedCount }); return c.json({ approved: results.filter(r => r.success).map(r => r.requestId), failed: results.filter(r => !r.success), @@ -223,7 +233,9 @@ adminApi.post('/storage/sync', async (c) => { const sandbox = c.get('sandbox'); const result = await syncToR2(sandbox, c.env); - + + auditLog('storage.sync', { success: result.success }); + if (result.success) { return c.json({ success: true, @@ -265,9 +277,11 @@ adminApi.post('/gateway/restart', async (c) => { }); c.executionCtx.waitUntil(bootPromise); + auditLog('gateway.restart', { previousProcessId: existingProcess?.id }); + return c.json({ success: true, - message: existingProcess + message: existingProcess ? 'Gateway process killed, new instance starting...' : 'No existing process found, starting new instance...', previousProcessId: existingProcess?.id, diff --git a/src/routes/cdp.ts b/src/routes/cdp.ts index 1d78e4911..742c16c43 100644 --- a/src/routes/cdp.ts +++ b/src/routes/cdp.ts @@ -8,7 +8,8 @@ import puppeteer, { type Browser, type Page } from '@cloudflare/puppeteer'; * Implements a subset of the CDP protocol over WebSocket, translating commands * to Cloudflare Browser Rendering binding calls (Puppeteer interface). * - * Authentication: Pass secret as query param `?secret=` on WebSocket connect. + * Authentication: Pass secret via `Authorization: Bearer ` header (preferred) + * or as query param `?secret=` (fallback for WebSocket clients). * This route is intentionally NOT protected by Cloudflare Access. * * Supported CDP domains: @@ -23,6 +24,47 @@ import puppeteer, { type Browser, type Page } from '@cloudflare/puppeteer'; */ const cdp = new Hono(); +/** + * Extract CDP secret from request — checks Authorization header first, then query param. + */ +function extractCDPSecret(c: { req: { header: (name: string) => string | undefined; url: string } }): string | null { + // Prefer Authorization: Bearer header + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + // Fall back to query param for WebSocket compatibility + const url = new URL(c.req.url); + return url.searchParams.get('secret'); +} + +/** + * Verify CDP auth and browser binding. Returns an error Response or null if OK. + */ +function verifyCDPAuth(c: { req: { header: (name: string) => string | undefined; url: string }; json: (data: unknown, status?: number) => Response; env: MoltbotEnv }): Response | null { + const expectedSecret = c.env.CDP_SECRET; + if (!expectedSecret) { + return c.json({ + error: 'CDP endpoint not configured', + hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', + }, 503); + } + + const providedSecret = extractCDPSecret(c); + if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { + return c.json({ error: 'Unauthorized' }, 401); + } + + if (!c.env.BROWSER) { + return c.json({ + error: 'Browser Rendering not configured', + hint: 'Add browser binding to wrangler.jsonc', + }, 503); + } + + return null; +} + /** * CDP Message types */ @@ -71,7 +113,7 @@ cdp.get('/', async (c) => { if (upgradeHeader?.toLowerCase() !== 'websocket') { return c.json({ error: 'WebSocket upgrade required', - hint: 'Connect via WebSocket: ws://host/cdp?secret=', + hint: 'Connect via WebSocket with Authorization: Bearer header or ws://host/cdp?secret=', supported_methods: [ // Browser 'Browser.getVersion', @@ -152,28 +194,8 @@ cdp.get('/', async (c) => { }); } - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } - - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; // Create WebSocket pair const webSocketPair = new WebSocketPair(); @@ -201,28 +223,11 @@ cdp.get('/', async (c) => { * Authentication: Pass secret as query param `?secret=` */ cdp.get('/json/version', async (c) => { - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL - preserve the secret in the WS URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -247,28 +252,11 @@ cdp.get('/json/version', async (c) => { * Authentication: Pass secret as query param `?secret=` */ cdp.get('/json/list', async (c) => { - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -292,31 +280,11 @@ cdp.get('/json/list', async (c) => { * GET /json - Alias for /json/list (some clients use this) */ cdp.get('/json', async (c) => { - // Redirect internally to /json/list handler - const url = new URL(c.req.url); - url.pathname = url.pathname.replace(/\/json\/?$/, '/json/list'); - - // Verify secret from query param - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -1837,16 +1805,15 @@ function sendEvent(ws: WebSocket, method: string, params?: Record cmd.startsWith(prefix)); +} /** * Debug routes for inspecting container state @@ -98,7 +122,14 @@ debug.get('/gateway-api', async (c) => { const sandbox = c.get('sandbox'); const path = c.req.query('path') || '/'; const MOLTBOT_PORT = 18789; - + + // Validate path: must start with / and must not contain path traversal + if (!path.startsWith('/') || path.includes('..')) { + return c.json({ error: 'Invalid path: must start with / and not contain ..' }, 400); + } + + auditLog('debug.gateway_api', { path }); + try { const url = `http://localhost:${MOLTBOT_PORT}${path}`; const response = await sandbox.containerFetch(new Request(url), MOLTBOT_PORT); @@ -127,7 +158,17 @@ debug.get('/gateway-api', async (c) => { debug.get('/cli', async (c) => { const sandbox = c.get('sandbox'); const cmd = c.req.query('cmd') || 'clawdbot --help'; - + + if (!isAllowedCommand(cmd)) { + auditLog('debug.cli_blocked', { cmd }); + return c.json({ + error: 'Command not allowed', + hint: 'Only clawdbot, node --version, ls, ps, cat /root/.clawdbot/*, whoami, uname, df, and free are permitted', + }, 403); + } + + auditLog('debug.cli', { cmd }); + try { const proc = await sandbox.startProcess(cmd); diff --git a/src/utils/audit.ts b/src/utils/audit.ts new file mode 100644 index 000000000..fe94d70c5 --- /dev/null +++ b/src/utils/audit.ts @@ -0,0 +1,25 @@ +/** + * Lightweight audit logging for security-relevant events. + * Outputs structured JSON to console for easy parsing in log aggregators. + */ + +export type AuditEvent = + | 'auth.success' + | 'auth.failure' + | 'auth.bypass' + | 'device.approve' + | 'device.approve_all' + | 'gateway.restart' + | 'storage.sync' + | 'debug.cli' + | 'debug.cli_blocked' + | 'debug.gateway_api'; + +export function auditLog(event: AuditEvent, details: Record = {}): void { + console.log(JSON.stringify({ + audit: true, + event, + ts: new Date().toISOString(), + ...details, + })); +} diff --git a/src/utils/shell.ts b/src/utils/shell.ts new file mode 100644 index 000000000..2925ee1d0 --- /dev/null +++ b/src/utils/shell.ts @@ -0,0 +1,11 @@ +/** + * Validates that a string is safe to use as a shell argument. + * Only allows alphanumeric characters, hyphens, and underscores. + * Throws an error if the input contains unsafe characters. + */ +export function validateShellArg(value: string, paramName: string): string { + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + throw new Error(`Invalid ${paramName}: contains unsafe characters`); + } + return value; +} diff --git a/start-moltbot.sh b/start-moltbot.sh index a4fbe22a6..4c2f22b62 100644 --- a/start-moltbot.sh +++ b/start-moltbot.sh @@ -20,7 +20,7 @@ cat > /root/.clawdbot/clawdbot.json << 'EOF' "enabled": true, "allowInsecureAuth": true }, - "trustedProxies": ["0.0.0.0/0"] + "trustedProxies": ["127.0.0.0/8", "::1/128"] } } EOF From bbbc2b46cfc37a949842f69c26270e5d9647b928 Mon Sep 17 00:00:00 2001 From: mattalvaro Date: Sat, 7 Feb 2026 10:44:21 +0800 Subject: [PATCH 3/3] chore: bump GATEWAY_VERSION to 17 to force restart after security deploy Co-Authored-By: Claude Opus 4.6 --- src/gateway/process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/process.ts b/src/gateway/process.ts index d512e6f8f..d07eead8b 100644 --- a/src/gateway/process.ts +++ b/src/gateway/process.ts @@ -39,7 +39,7 @@ export async function findExistingMoltbotProcess(sandbox: Sandbox): Promise