refactor: route browser control via gateway/node
This commit is contained in:
@@ -73,7 +73,7 @@ export function collectAttackSurfaceSummaryFindings(cfg: ClawdbotConfig): Securi
|
||||
const group = summarizeGroupPolicy(cfg);
|
||||
const elevated = cfg.tools?.elevated?.enabled !== false;
|
||||
const hooksEnabled = cfg.hooks?.enabled === true;
|
||||
const browserEnabled = Boolean(cfg.browser?.enabled ?? cfg.browser?.controlUrl);
|
||||
const browserEnabled = cfg.browser?.enabled ?? true;
|
||||
|
||||
const detail =
|
||||
`groups: open=${group.open}, allowlist=${group.allowlist}` +
|
||||
@@ -143,20 +143,6 @@ export function collectSecretsInConfigFindings(cfg: ClawdbotConfig): SecurityAud
|
||||
});
|
||||
}
|
||||
|
||||
const browserToken =
|
||||
typeof cfg.browser?.controlToken === "string" ? cfg.browser.controlToken.trim() : "";
|
||||
if (browserToken && !looksLikeEnvRef(browserToken)) {
|
||||
findings.push({
|
||||
checkId: "config.secrets.browser_control_token_in_config",
|
||||
severity: "warn",
|
||||
title: "Browser control token is stored in config",
|
||||
detail:
|
||||
"browser.controlToken is set in the config file; prefer environment variables for secrets when possible.",
|
||||
remediation:
|
||||
"Prefer CLAWDBOT_BROWSER_CONTROL_TOKEN (env) and remove browser.controlToken from disk.",
|
||||
});
|
||||
}
|
||||
|
||||
const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
|
||||
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
|
||||
findings.push({
|
||||
@@ -206,21 +192,6 @@ export function collectHooksHardeningFindings(cfg: ClawdbotConfig): SecurityAudi
|
||||
});
|
||||
}
|
||||
|
||||
const browserToken =
|
||||
typeof cfg.browser?.controlToken === "string" && cfg.browser.controlToken.trim()
|
||||
? cfg.browser.controlToken.trim()
|
||||
: process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || null;
|
||||
if (token && browserToken && token === browserToken) {
|
||||
findings.push({
|
||||
checkId: "hooks.token_reuse_browser_token",
|
||||
severity: "warn",
|
||||
title: "Hooks token reuses the browser control token",
|
||||
detail:
|
||||
"hooks.token matches browser control token; compromise of hooks may enable browser control endpoints.",
|
||||
remediation: "Use a separate hooks.token dedicated to hook ingress.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "";
|
||||
if (rawPath === "/") {
|
||||
findings.push({
|
||||
@@ -457,7 +428,7 @@ function isWebFetchEnabled(cfg: ClawdbotConfig): boolean {
|
||||
|
||||
function isBrowserEnabled(cfg: ClawdbotConfig): boolean {
|
||||
try {
|
||||
return resolveBrowserConfig(cfg.browser).enabled;
|
||||
return resolveBrowserConfig(cfg.browser, cfg).enabled;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -274,41 +274,13 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("flags remote browser control without token as critical", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
browser: {
|
||||
controlUrl: "http://example.com:18791",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.control_remote_no_token",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when browser control token matches gateway auth token", async () => {
|
||||
const token = "0123456789abcdef0123456789abcdef";
|
||||
it("warns when remote CDP uses HTTP", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: { auth: { token } },
|
||||
browser: { controlUrl: "https://browser.example.com", controlToken: token },
|
||||
browser: {
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://example.com:9222", color: "#0066CC" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
@@ -319,42 +291,11 @@ describe("security audit", () => {
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.control_token_reuse_gateway_token",
|
||||
severity: "warn",
|
||||
}),
|
||||
expect.objectContaining({ checkId: "browser.remote_cdp_http", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when remote browser control uses HTTP", async () => {
|
||||
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
try {
|
||||
const cfg: ClawdbotConfig = {
|
||||
browser: {
|
||||
controlUrl: "http://example.com:18791",
|
||||
controlToken: "0123456789abcdef01234567",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ checkId: "browser.control_remote_http", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
|
||||
else process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when control UI allows insecure auth", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user