forked from molecule-ai/molecule-core
Merge pull request #295 from Molecule-AI/fix/cdp-proxy-bind-localhost
fix(security): token-auth on cdp-proxy to prevent LAN exposure (#293)
This commit is contained in:
commit
5ba54ba574
@ -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)));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user