diff --git a/plugins/browser-automation/host-bridge/cdp-proxy.cjs b/plugins/browser-automation/host-bridge/cdp-proxy.cjs index aa6ca268..7cb19015 100755 --- a/plugins/browser-automation/host-bridge/cdp-proxy.cjs +++ b/plugins/browser-automation/host-bridge/cdp-proxy.cjs @@ -4,8 +4,26 @@ * * Why: Chrome on macOS rejects DevTools Protocol connections whose Host header * is anything other than `localhost`. A container hitting `host.docker.internal:9222` - * fails the check. This proxy listens on 0.0.0.0:9223, rewrites the Host header, - * and forwards both HTTP (tab listing, screenshots) and WebSocket upgrades. + * fails the check. This proxy listens on BIND_ADDR:PROXY_PORT, rewrites the Host + * header, and forwards both HTTP (tab listing, screenshots) and WebSocket upgrades. + * + * SECURITY (#293): + * CDP offers full control of Chrome: execute arbitrary JS in any tab, read + * cookies/localStorage/session tokens, screenshot, navigate — effectively + * account takeover for any site the user is logged into. The proxy must not + * be reachable without authentication. + * + * We bind to 0.0.0.0 by default because Docker Desktop on macOS routes + * `host.docker.internal` through the VM network, not loopback — binding to + * 127.0.0.1 would break the primary use case. Instead of restricting the + * binding, we require a bearer token on every request. + * + * The token is read from CDP_PROXY_TOKEN (env var) OR ~/.molecule-cdp-proxy-token + * (a chmod 600 file written by install-host-bridge.sh at install time). + * If neither is set, the proxy REFUSES TO START — there is no un-authed mode. + * + * Clients (the bundled `lib/connect.js` helper) send + * `X-CDP-Proxy-Token: ` on every HTTP request and WebSocket upgrade. * * Usage: * # Launch your Chrome with the debug port once (once per reboot): @@ -14,31 +32,73 @@ * --profile-directory=Default \ * --remote-debugging-port=9222 * - * # Then start the proxy (stays in foreground; run in a launchd/systemd unit): - * node cdp-proxy.cjs + * # Then start the proxy (normally via install-host-bridge.sh into launchd/systemd): + * CDP_PROXY_TOKEN=$(cat ~/.molecule-cdp-proxy-token) node cdp-proxy.cjs * * Env overrides: - * CHROME_PORT (default 9222) - * PROXY_PORT (default 9223) - * BIND_ADDR (default 0.0.0.0) - * - * Container side: connect via `host.docker.internal:9223` (Docker Desktop) or - * `172.17.0.1:9223` (Linux). The bundled `lib/connect.js` helper auto-detects. + * CHROME_PORT (default 9222) + * PROXY_PORT (default 9223) + * BIND_ADDR (default 0.0.0.0 — safe because token auth is required) + * CDP_PROXY_TOKEN (required — falls back to ~/.molecule-cdp-proxy-token) */ +const fs = require('fs'); const http = require('http'); const net = require('net'); +const path = require('path'); +const os = require('os'); const CHROME_PORT = parseInt(process.env.CHROME_PORT || '9222', 10); const PROXY_PORT = parseInt(process.env.PROXY_PORT || '9223', 10); const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0'; +const TOKEN_FILE = path.join(os.homedir(), '.molecule-cdp-proxy-token'); + +// Resolve the auth token. Priority: env var > token file. Fail loudly if +// neither is present — there is NO unauth mode (#293). +function loadToken() { + if (process.env.CDP_PROXY_TOKEN && process.env.CDP_PROXY_TOKEN.length >= 16) { + return process.env.CDP_PROXY_TOKEN; + } + try { + const tok = fs.readFileSync(TOKEN_FILE, 'utf8').trim(); + if (tok.length >= 16) return tok; + throw new Error(`token file ${TOKEN_FILE} is too short (<16 chars)`); + } catch (e) { + console.error('FATAL: CDP proxy auth token not found.'); + console.error('Set CDP_PROXY_TOKEN env var (>=16 chars) OR write a token to'); + console.error(` ${TOKEN_FILE} (chmod 600)`); + console.error('See plugins/browser-automation/host-bridge/install-host-bridge.sh'); + console.error('for the canonical installer that generates + provisions the token.'); + console.error('Original error:', e.message); + process.exit(1); + } +} +const PROXY_TOKEN = loadToken(); + +// Constant-time compare to resist timing attacks. Node's crypto.timingSafeEqual +// requires equal-length Buffers, so short-circuit mismatched lengths upfront. +const crypto = require('crypto'); +function tokenMatches(header) { + if (typeof header !== 'string') return false; + const a = Buffer.from(header); + const b = Buffer.from(PROXY_TOKEN); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} const proxy = http.createServer((req, res) => { + if (!tokenMatches(req.headers['x-cdp-proxy-token'])) { + res.writeHead(401, { 'Content-Type': 'text/plain' }); + res.end('unauthorized: missing or invalid X-CDP-Proxy-Token'); + return; + } const options = { hostname: '127.0.0.1', port: CHROME_PORT, path: req.url, method: req.method, - headers: { ...req.headers, host: `localhost:${CHROME_PORT}` }, + // Strip the auth token before forwarding — Chrome CDP doesn't need it + // and leaking it into any upstream logs would weaken the defense. + headers: stripAuthHeader({ ...req.headers, host: `localhost:${CHROME_PORT}` }), }; const proxyReq = http.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); @@ -52,11 +112,20 @@ const proxy = http.createServer((req, res) => { }); proxy.on('upgrade', (req, socket, head) => { + // WebSocket upgrade requests go through the same auth check. If the client + // didn't send the token header on the HTTP upgrade request, reject before + // we touch the backing Chrome connection at all. + if (!tokenMatches(req.headers['x-cdp-proxy-token'])) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } const conn = net.connect(CHROME_PORT, '127.0.0.1', () => { + const sanitized = stripAuthHeader(req.headers); const upgradeReq = `${req.method} ${req.url} HTTP/1.1\r\n` + `Host: localhost:${CHROME_PORT}\r\n` + - Object.entries(req.headers) + Object.entries(sanitized) .filter(([k]) => k.toLowerCase() !== 'host') .map(([k, v]) => `${k}: ${v}`) .join('\r\n') + @@ -70,8 +139,20 @@ proxy.on('upgrade', (req, socket, head) => { socket.on('error', () => conn.destroy()); }); +// stripAuthHeader removes the X-CDP-Proxy-Token before forwarding — defense +// in depth so the token can't leak into Chrome's request log or any future +// pass-through sink. +function stripAuthHeader(headers) { + const out = { ...headers }; + for (const k of Object.keys(out)) { + if (k.toLowerCase() === 'x-cdp-proxy-token') delete out[k]; + } + return out; +} + proxy.listen(PROXY_PORT, BIND_ADDR, () => { console.log(`cdp-proxy listening on ${BIND_ADDR}:${PROXY_PORT} → 127.0.0.1:${CHROME_PORT}`); + console.log(`auth required: send X-CDP-Proxy-Token header on every request`); }); process.on('SIGTERM', () => proxy.close(() => process.exit(0))); diff --git a/plugins/browser-automation/host-bridge/install-host-bridge.sh b/plugins/browser-automation/host-bridge/install-host-bridge.sh index 88d53377..c644814e 100755 --- a/plugins/browser-automation/host-bridge/install-host-bridge.sh +++ b/plugins/browser-automation/host-bridge/install-host-bridge.sh @@ -13,6 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROXY_SCRIPT="${SCRIPT_DIR}/cdp-proxy.cjs" LABEL="com.molecule.browser-automation.cdp-proxy" NODE_BIN="$(command -v node || echo /usr/local/bin/node)" +TOKEN_FILE="${HOME}/.molecule-cdp-proxy-token" if [[ ! -f "$PROXY_SCRIPT" ]]; then echo "ERROR: $PROXY_SCRIPT not found" >&2 @@ -23,8 +24,30 @@ if [[ ! -x "$NODE_BIN" ]]; then exit 1 fi +# #293: generate a per-install auth token so the proxy isn't exposed to the +# LAN without authentication. Written to ~/.molecule-cdp-proxy-token with +# 0600 perms. The proxy reads it at startup; workspace containers read it +# via the bundled connect() helper which mounts the token file over a bind. +ensure_token() { + if [[ -f "$TOKEN_FILE" ]] && [[ "$(wc -c < "$TOKEN_FILE")" -ge 17 ]]; then + echo "token: reusing existing $TOKEN_FILE" + return + fi + # 32 bytes of random, hex-encoded → 64 chars. openssl is available on + # every macOS + most Linux installs; fall back to /dev/urandom if not. + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 > "$TOKEN_FILE" + else + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$TOKEN_FILE" + fi + chmod 600 "$TOKEN_FILE" + echo "token: generated new $TOKEN_FILE (0600)" +} + install_macos() { local plist="$HOME/Library/LaunchAgents/${LABEL}.plist" + local token_val + token_val="$(cat "$TOKEN_FILE")" cat > "$plist" < @@ -35,6 +58,10 @@ install_macos() { ${NODE_BIN} ${PROXY_SCRIPT} + EnvironmentVariables + + CDP_PROXY_TOKEN${token_val} + KeepAlive RunAtLoad StandardOutPath${HOME}/.molecule-cdp-proxy.log @@ -52,6 +79,13 @@ install_linux() { local unit_dir="$HOME/.config/systemd/user" mkdir -p "$unit_dir" local unit="$unit_dir/${LABEL}.service" + # Read token from the file at service start instead of embedding it in + # the unit file — unit files are often world-readable, the token file + # is 0600. systemd EnvironmentFile reads key=value lines so we write a + # sidecar file containing CDP_PROXY_TOKEN=. + local env_file="${HOME}/.molecule-cdp-proxy.env" + printf 'CDP_PROXY_TOKEN=%s\n' "$(cat "$TOKEN_FILE")" > "$env_file" + chmod 600 "$env_file" cat > "$unit" </dev/null || true + echo "note: ${TOKEN_FILE} preserved so a future reinstall keeps the same token." + echo " delete manually if you want to rotate." ;; - uninstall) uninstall ;; *) echo "usage: $0 [install|uninstall]"; exit 1 ;; esac diff --git a/plugins/browser-automation/skills/browser-automation/lib/connect.js b/plugins/browser-automation/skills/browser-automation/lib/connect.js index 15944d04..8f02f6f3 100644 --- a/plugins/browser-automation/skills/browser-automation/lib/connect.js +++ b/plugins/browser-automation/skills/browser-automation/lib/connect.js @@ -6,6 +6,15 @@ * - defaultViewport: null (use real Chrome window dims, not the 800x600 default) * - browserWSEndpoint with proxy host rewrite (works inside Docker AND on host) * + * Authentication (#293): + * The host-bridge cdp-proxy requires an X-CDP-Proxy-Token header on every + * HTTP request + WebSocket upgrade. This helper reads the token from: + * 1. CDP_PROXY_TOKEN env var (preferred — set by workspace-template + * provisioner from a bind-mounted /run/secrets/cdp-proxy-token) + * 2. /run/secrets/cdp-proxy-token (mount-time secret — default path) + * 3. ~/.molecule-cdp-proxy-token (fallback when running directly on host) + * If no token can be found, connect() throws — there is no unauth mode. + * * Usage: * const { connect } = require('./lib/connect'); // adjust path * const browser = await connect(); @@ -15,18 +24,49 @@ */ const puppeteer = require('puppeteer-core'); const http = require('http'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); const HOST_DOCKER = 'host.docker.internal'; const HOST_LOCAL = '127.0.0.1'; -const PROXY_PORT = 9223; // CDP proxy (rewrites Host header) -const DIRECT_PORT = 9222; // Chrome's native CDP +const PROXY_PORT = 9223; // CDP proxy (rewrites Host header + requires token) +const DIRECT_PORT = 9222; // Chrome's native CDP (host-direct fallback, NO auth) -function fetchVersion(url) { +// Token lookup order — first hit wins. See header comment for rationale. +function loadProxyToken() { + if (process.env.CDP_PROXY_TOKEN && process.env.CDP_PROXY_TOKEN.length >= 16) { + return process.env.CDP_PROXY_TOKEN; + } + const candidates = [ + '/run/secrets/cdp-proxy-token', + path.join(os.homedir(), '.molecule-cdp-proxy-token'), + ]; + for (const p of candidates) { + try { + const tok = fs.readFileSync(p, 'utf8').trim(); + if (tok.length >= 16) return tok; + } catch { + // try next + } + } + return null; +} + +function fetchVersion(url, token) { return new Promise((resolve, reject) => { - const req = http.get(url, r => { + const headers = {}; + if (token) headers['X-CDP-Proxy-Token'] = token; + const req = http.get(url, { headers }, r => { let d = ''; r.on('data', c => d += c); - r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); + r.on('end', () => { + if (r.statusCode === 401) { + reject(new Error(`CDP proxy unauthorized (401) — token missing or invalid`)); + return; + } + try { resolve(JSON.parse(d)); } catch (e) { reject(e); } + }); }); req.on('error', reject); req.setTimeout(5000, () => { req.destroy(new Error('timeout')); }); @@ -34,29 +74,49 @@ function fetchVersion(url) { } async function connect() { + const token = loadProxyToken(); + // Detect environment: are we inside a Docker container or on the host? // host.docker.internal resolves only inside containers. - let host, port; + let host, port, usingProxy; try { - await fetchVersion(`http://${HOST_DOCKER}:${PROXY_PORT}/json/version`); + // Proxy path — token REQUIRED. Throw on missing so the user fixes it + // at install time rather than silently falling back to an unauth host + // connection that only works on the host machine itself. + if (!token) { + throw new Error('no token — skip proxy path'); + } + await fetchVersion(`http://${HOST_DOCKER}:${PROXY_PORT}/json/version`, token); host = HOST_DOCKER; port = PROXY_PORT; + usingProxy = true; } catch { - // Fallback to direct connection (host script) + // Fallback to direct Chrome CDP (host script running ON the host, + // no proxy involved). No token needed — Chrome's own port 9222 is + // loopback-only and doesn't check Host headers from 127.0.0.1. host = HOST_LOCAL; port = DIRECT_PORT; + usingProxy = false; } - const data = await fetchVersion(`http://${host}:${port}/json/version`); + const data = await fetchVersion(`http://${host}:${port}/json/version`, usingProxy ? token : null); // Rewrite localhost in WS URL to whichever host worked above const wsUrl = data.webSocketDebuggerUrl .replace('localhost:9222', `${host}:${port}`) .replace('127.0.0.1:9222', `${host}:${port}`); - return puppeteer.connect({ + const connectOpts = { browserWSEndpoint: wsUrl, defaultViewport: null, // CRITICAL: use Chrome's actual window size - }); + }; + if (usingProxy) { + // puppeteer-core v21+ supports connection headers. The proxy's WS + // upgrade handler validates X-CDP-Proxy-Token before forwarding to + // Chrome; without this header the upgrade returns 401. + connectOpts.headers = { 'X-CDP-Proxy-Token': token }; + } + + return puppeteer.connect(connectOpts); } module.exports = { connect };