refactor: route browser control via gateway/node

This commit is contained in:
Peter Steinberger
2026-01-27 03:23:42 +00:00
parent b151b8d196
commit e7fdccce39
91 changed files with 1909 additions and 1608 deletions

View File

@@ -356,82 +356,41 @@ function collectGatewayConfigFindings(
return findings;
}
function isLoopbackClientHost(hostname: string): boolean {
const h = hostname.trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1";
}
function collectBrowserControlFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
let resolved: ReturnType<typeof resolveBrowserConfig>;
try {
resolved = resolveBrowserConfig(cfg.browser);
resolved = resolveBrowserConfig(cfg.browser, cfg);
} catch (err) {
findings.push({
checkId: "browser.control_invalid_config",
severity: "warn",
title: "Browser control config looks invalid",
detail: String(err),
remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("clawdbot security audit --deep")}".`,
});
return findings;
}
if (!resolved.enabled) return findings;
const url = new URL(resolved.controlUrl);
const isLoopback = isLoopbackClientHost(url.hostname);
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
const controlToken = (envToken || resolved.controlToken)?.trim() || null;
if (!isLoopback) {
if (!controlToken) {
findings.push({
checkId: "browser.control_remote_no_token",
severity: "critical",
title: "Remote browser control is missing an auth token",
detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`,
remediation:
"Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.",
});
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.cdpIsLoopback) continue;
let url: URL;
try {
url = new URL(profile.cdpUrl);
} catch {
continue;
}
if (url.protocol === "http:") {
findings.push({
checkId: "browser.control_remote_http",
checkId: "browser.remote_cdp_http",
severity: "warn",
title: "Remote browser control uses HTTP",
detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`,
remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`,
});
}
if (controlToken && controlToken.length < 24) {
findings.push({
checkId: "browser.control_token_too_short",
severity: "warn",
title: "Browser control token looks short",
detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`,
});
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const gatewayToken =
gatewayAuth.mode === "token" &&
typeof gatewayAuth.token === "string" &&
gatewayAuth.token.trim()
? gatewayAuth.token.trim()
: null;
if (controlToken && gatewayToken && controlToken === gatewayToken) {
findings.push({
checkId: "browser.control_token_reuse_gateway_token",
severity: "warn",
title: "Browser control token reuses the Gateway token",
detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`,
remediation: `Use a separate browser.controlToken dedicated to browser control.`,
title: "Remote CDP uses HTTP",
detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
});
}
}