Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
71
src/commands/doctor-security.test.ts
Normal file
71
src/commands/doctor-security.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const note = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../terminal/note.js", () => ({
|
||||||
|
note,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../channels/plugins/index.js", () => ({
|
||||||
|
listChannelPlugins: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||||
|
|
||||||
|
describe("noteSecurityWarnings gateway exposure", () => {
|
||||||
|
let prevToken: string | undefined;
|
||||||
|
let prevPassword: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
note.mockClear();
|
||||||
|
prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
if (prevPassword === undefined) delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
else process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? "");
|
||||||
|
|
||||||
|
it("warns when exposed without auth", async () => {
|
||||||
|
const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
|
||||||
|
await noteSecurityWarnings(cfg);
|
||||||
|
const message = lastMessage();
|
||||||
|
expect(message).toContain("CRITICAL");
|
||||||
|
expect(message).toContain("without authentication");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses env token to avoid critical warning", async () => {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = "token-123";
|
||||||
|
const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
|
||||||
|
await noteSecurityWarnings(cfg);
|
||||||
|
const message = lastMessage();
|
||||||
|
expect(message).toContain("WARNING");
|
||||||
|
expect(message).not.toContain("CRITICAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats whitespace token as missing", async () => {
|
||||||
|
const cfg = {
|
||||||
|
gateway: { bind: "lan", auth: { mode: "token", token: " " } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await noteSecurityWarnings(cfg);
|
||||||
|
const message = lastMessage();
|
||||||
|
expect(message).toContain("CRITICAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips warning for loopback bind", async () => {
|
||||||
|
const cfg = { gateway: { bind: "loopback" } } as ClawdbotConfig;
|
||||||
|
await noteSecurityWarnings(cfg);
|
||||||
|
const message = lastMessage();
|
||||||
|
expect(message).toContain("No channel security warnings detected");
|
||||||
|
expect(message).not.toContain("Gateway bound");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig, GatewayBindMode } from "../config/config.js";
|
||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
|
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||||
|
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
|
||||||
|
|
||||||
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -16,50 +18,55 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
|||||||
// Check for dangerous gateway binding configurations
|
// Check for dangerous gateway binding configurations
|
||||||
// that expose the gateway to network without proper auth
|
// that expose the gateway to network without proper auth
|
||||||
|
|
||||||
const gatewayBind = cfg.gateway?.bind ?? "loopback";
|
const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string;
|
||||||
const customBindHost = cfg.gateway?.customBindHost?.trim();
|
const customBindHost = cfg.gateway?.customBindHost?.trim();
|
||||||
const authMode = cfg.gateway?.auth?.mode ?? "off";
|
const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"];
|
||||||
const authToken = cfg.gateway?.auth?.token;
|
const bindMode = bindModes.includes(gatewayBind as GatewayBindMode)
|
||||||
const authPassword = cfg.gateway?.auth?.password;
|
? (gatewayBind as GatewayBindMode)
|
||||||
|
: undefined;
|
||||||
|
const resolvedBindHost = bindMode
|
||||||
|
? await resolveGatewayBindHost(bindMode, customBindHost)
|
||||||
|
: "0.0.0.0";
|
||||||
|
const isExposed = !isLoopbackHost(resolvedBindHost);
|
||||||
|
|
||||||
const isLoopbackBindHost = (host: string) => {
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
const normalized = host.trim().toLowerCase();
|
authConfig: cfg.gateway?.auth,
|
||||||
return (
|
env: process.env,
|
||||||
normalized === "localhost" ||
|
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||||
normalized === "::1" ||
|
});
|
||||||
normalized === "[::1]" ||
|
const authToken = resolvedAuth.token?.trim() ?? "";
|
||||||
normalized.startsWith("127.")
|
const authPassword = resolvedAuth.password?.trim() ?? "";
|
||||||
);
|
const hasToken = authToken.length > 0;
|
||||||
};
|
const hasPassword = authPassword.length > 0;
|
||||||
|
const hasSharedSecret =
|
||||||
// Bindings that expose gateway beyond localhost
|
(resolvedAuth.mode === "token" && hasToken) ||
|
||||||
const exposedBindings = ["all", "lan", "0.0.0.0"];
|
(resolvedAuth.mode === "password" && hasPassword);
|
||||||
const isExposed =
|
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
||||||
exposedBindings.includes(gatewayBind) ||
|
|
||||||
(gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
|
|
||||||
|
|
||||||
if (isExposed) {
|
if (isExposed) {
|
||||||
if (authMode === "off") {
|
if (!hasSharedSecret) {
|
||||||
|
const authFixLines =
|
||||||
|
resolvedAuth.mode === "password"
|
||||||
|
? [
|
||||||
|
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
|
||||||
|
` Or switch to token: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
|
||||||
|
` Or set token directly: ${formatCliCommand(
|
||||||
|
"clawdbot config set gateway.auth.mode token",
|
||||||
|
)}`,
|
||||||
|
];
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
|
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
|
||||||
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
||||||
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
|
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
|
||||||
` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
|
...authFixLines,
|
||||||
);
|
|
||||||
} else if (authMode === "token" && !authToken) {
|
|
||||||
warnings.push(
|
|
||||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
|
|
||||||
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
|
|
||||||
);
|
|
||||||
} else if (authMode === "password" && !authPassword) {
|
|
||||||
warnings.push(
|
|
||||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
|
|
||||||
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Auth is configured, but still warn about network exposure
|
// Auth is configured, but still warn about network exposure
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
|
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
|
||||||
` Ensure your auth credentials are strong and not exposed.`,
|
` Ensure your auth credentials are strong and not exposed.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user