import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; import { note } from "../terminal/note.js"; export async function noteSecurityWarnings(cfg: OpenClawConfig) { const warnings: string[] = []; const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`; // =========================================== // GATEWAY NETWORK EXPOSURE CHECK // =========================================== // Check for dangerous gateway binding configurations // that expose the gateway to network without proper auth const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) ? (gatewayBind as GatewayBindMode) : undefined; const resolvedBindHost = bindMode ? await resolveGatewayBindHost(bindMode, customBindHost) : "0.0.0.0"; const isExposed = !isLoopbackHost(resolvedBindHost); const resolvedAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", }); const authToken = resolvedAuth.token?.trim() ?? ""; const authPassword = resolvedAuth.password?.trim() ?? ""; const hasToken = authToken.length > 0; const hasPassword = authPassword.length > 0; const hasSharedSecret = (resolvedAuth.mode === "token" && hasToken) || (resolvedAuth.mode === "password" && hasPassword); const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`; const saferRemoteAccessLines = [ " Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.", " Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host", " Docs: https://docs.openclaw.ai/gateway/remote", ]; if (isExposed) { if (!hasSharedSecret) { const authFixLines = resolvedAuth.mode === "password" ? [ ` Fix: ${formatCliCommand("openclaw configure")} to set a password`, ` Or switch to token: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`, ] : [ ` Fix: ${formatCliCommand("openclaw doctor --fix")} to generate a token`, ` Or set token directly: ${formatCliCommand( "openclaw config set gateway.auth.mode token", )}`, ]; warnings.push( `- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`, ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`, ` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`, ...saferRemoteAccessLines, ...authFixLines, ); } else { // Auth is configured, but still warn about network exposure warnings.push( `- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`, ` Ensure your auth credentials are strong and not exposed.`, ...saferRemoteAccessLines, ); } } const warnDmPolicy = async (params: { label: string; provider: ChannelId; dmPolicy: string; allowFrom?: Array | null; policyPath?: string; allowFromPath: string; approveHint: string; normalizeEntry?: (raw: string) => string; }) => { const dmPolicy = params.dmPolicy; const policyPath = params.policyPath ?? `${params.allowFromPath}policy`; const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({ provider: params.provider, allowFrom: params.allowFrom, normalizeEntry: params.normalizeEntry, }); const dmScope = cfg.session?.dmScope ?? "main"; if (dmPolicy === "open") { const allowFromPath = `${params.allowFromPath}allowFrom`; warnings.push(`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`); if (!hasWildcard) { warnings.push( `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, ); } } if (dmPolicy === "disabled") { warnings.push(`- ${params.label} DMs: disabled (${policyPath}="disabled").`); return; } if (dmPolicy !== "open" && allowCount === 0) { warnings.push( `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, ); warnings.push(` ${params.approveHint}`); } if (dmScope === "main" && isMultiUserDm) { warnings.push( `- ${params.label} DMs: multiple senders share the main session; run: ` + formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') + ' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', ); } }; for (const plugin of listChannelPlugins()) { if (!plugin.security) { continue; } const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); const account = plugin.config.resolveAccount(cfg, defaultAccountId); const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; if (!enabled) { continue; } const configured = plugin.config.isConfigured ? await plugin.config.isConfigured(account, cfg) : true; if (!configured) { continue; } const dmPolicy = plugin.security.resolveDmPolicy?.({ cfg, accountId: defaultAccountId, account, }); if (dmPolicy) { await warnDmPolicy({ label: plugin.meta.label ?? plugin.id, provider: plugin.id, dmPolicy: dmPolicy.policy, allowFrom: dmPolicy.allowFrom, policyPath: dmPolicy.policyPath, allowFromPath: dmPolicy.allowFromPath, approveHint: dmPolicy.approveHint, normalizeEntry: dmPolicy.normalizeEntry, }); } if (plugin.security.collectWarnings) { const extra = await plugin.security.collectWarnings({ cfg, accountId: defaultAccountId, account, }); if (extra?.length) { warnings.push(...extra); } } } const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."]; lines.push(auditHint); note(lines.join("\n"), "Security"); }