fix(security): token-auth on cdp-proxy to prevent LAN exposure (#293)

HIGH finding from security-auditor on PR #291 (merged tick-37). The
cdp-proxy bound to 0.0.0.0:9223 with no authentication, exposing
Chrome DevTools Protocol — full remote control of any tab, including
cookie/localStorage exfiltration — to anyone on the same WiFi/LAN.

Root cause: Docker Desktop on macOS routes host.docker.internal
through the VM network interface, not loopback. Binding to 127.0.0.1
would break the primary use case (containers reaching the host
Chrome). The design trade was "bind wide for reachability, accept LAN
exposure" — #293 makes that trade unacceptable.

Fix: bearer token auth on every HTTP + WebSocket request. The proxy
REFUSES TO START without a token — no unauth mode.

Three-file change:

1. cdp-proxy.cjs
   - Read token from CDP_PROXY_TOKEN env OR ~/.molecule-cdp-proxy-token
   - Fail loudly if neither is set (exit 1 with install-host-bridge.sh
     pointer)
   - Validate X-CDP-Proxy-Token header via crypto.timingSafeEqual on
     every HTTP request AND every WS upgrade
   - Strip the header before forwarding to Chrome (defense in depth —
     token never leaks into Chrome's request log)

2. install-host-bridge.sh
   - New ensure_token() function generates a 64-char hex token via
     openssl rand -hex 32 (fallback to /dev/urandom). Written to
     ~/.molecule-cdp-proxy-token with chmod 600.
   - macOS: token injected into launchd plist EnvironmentVariables
   - Linux: written to ~/.molecule-cdp-proxy.env (chmod 600) and
     referenced via systemd EnvironmentFile — avoids embedding the
     token in the often world-readable unit file
   - Install reuses existing token if present (16+ chars); uninstall
     preserves token file so a reinstall keeps the same token
   - Verify command now includes the token header
   - Documents container-side bind-mount pattern
     (-v ~/.molecule-cdp-proxy-token:/run/secrets/cdp-proxy-token:ro)

3. lib/connect.js
   - New loadProxyToken() with precedence: env var >
     /run/secrets/cdp-proxy-token > ~/.molecule-cdp-proxy-token
   - Attaches X-CDP-Proxy-Token header on both /json/version probe +
     final puppeteer.connect() call via headers: {} option
     (puppeteer-core v21+ supports this natively)
   - Host-direct fallback (CDP port 9222 on loopback) unchanged —
     Chrome's own port is loopback-only so it doesn't need the token

Attack surface now:
  - LAN attacker must also steal the token file from the user's home
    directory (requires shell access) OR the env var (requires
    launchd/systemd process inspection as the same user) — reduces to
    local-privilege-escalation territory
  - Containers on the same Docker network still have access (they
    mount the token by design) — intentional, any workspace-template
    install already runs inside the platform's trust boundary

Not fixing in this PR:
  - Rate limiting on /json/version (low priority — probe-and-mine is
    expensive even without)
  - IP allowlist on top of token auth (diminishing returns)
  - Rotating the token periodically (user can rm ~/.molecule-cdp-proxy-token
    and reinstall)

Closes #293.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 18:00:02 -07:00
parent 5ff32c533c
commit 8cc325eb3b
3 changed files with 211 additions and 25 deletions

View File

@ -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: <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)));

View File

@ -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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -35,6 +58,10 @@ install_macos() {
<string>${NODE_BIN}</string>
<string>${PROXY_SCRIPT}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CDP_PROXY_TOKEN</key><string>${token_val}</string>
</dict>
<key>KeepAlive</key><true/>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>${HOME}/.molecule-cdp-proxy.log</string>
@ -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=<value>.
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" <<EOF
[Unit]
Description=Molecule browser-automation CDP proxy (host → Chrome)
@ -59,6 +93,7 @@ After=network-online.target
[Service]
Type=simple
EnvironmentFile=${env_file}
ExecStart=${NODE_BIN} ${PROXY_SCRIPT}
Restart=always
RestartSec=5
@ -90,6 +125,7 @@ uninstall() {
case "${1:-install}" in
install)
ensure_token
case "$(uname -s)" in
Darwin) install_macos ;;
Linux) install_linux ;;
@ -98,8 +134,17 @@ case "${1:-install}" in
echo
echo "next step: launch your Chrome with --remote-debugging-port=9222 (once per reboot)"
echo " macOS: open -na 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=\"\$HOME/.chrome-molecule\""
echo "verify: curl http://127.0.0.1:9223/json/version"
echo "verify: curl -H \"X-CDP-Proxy-Token: \$(cat $TOKEN_FILE)\" http://127.0.0.1:9223/json/version"
echo
echo "container side: mount $TOKEN_FILE into each workspace and the bundled"
echo "lib/connect.js helper will read it automatically. Bind:"
echo " -v $TOKEN_FILE:/run/secrets/cdp-proxy-token:ro"
;;
uninstall)
uninstall
rm -f "${HOME}/.molecule-cdp-proxy.env" 2>/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

View File

@ -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 };