diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 3579cb5d9..514233165 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -6,7 +6,7 @@ import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { note } from "../terminal/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; +import { isLoopbackHost, resolveGatewayBindHost, getPublicIPs, isPrivateIP } from "../gateway/net.js"; export async function noteSecurityWarnings(cfg: OpenClawConfig) { const warnings: string[] = []; @@ -44,6 +44,12 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`; if (isExposed) { + // Check for public IP exposure (this is how installations end up on Shodan) + const publicIPs = getPublicIPs(); + const hasPublicIP = publicIPs.length > 0; + const isBindingToPublicIP = + resolvedBindHost === "0.0.0.0" || !isPrivateIP(resolvedBindHost); + if (!hasSharedSecret) { const authFixLines = resolvedAuth.mode === "password" @@ -70,6 +76,16 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { ` Ensure your auth credentials are strong and not exposed.`, ); } + + // Additional warning for public IP exposure (Shodan risk) + if (isBindingToPublicIP && hasPublicIP) { + warnings.push( + `- WARNING: System has public IP(s): ${publicIPs.join(", ")}`, + ` Binding to ${resolvedBindHost} exposes your gateway to the internet.`, + ` This is how installations end up on Shodan.`, + ` For remote access, consider: VPN/Tailscale (preferred) or HTTPS reverse proxy.`, + ); + } } const warnDmPolicy = async (params: { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 6702e0e8b..478eeb71f 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,7 +1,92 @@ import net from "node:net"; +import os from "node:os"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; +/** + * Check if an IPv4 address is in a private range (RFC 1918 + loopback + link-local). + * + * Private ranges: + * - 10.0.0.0/8 + * - 172.16.0.0/12 + * - 192.168.0.0/16 + * - 127.0.0.0/8 (loopback) + * - 169.254.0.0/16 (link-local) + */ +export function isPrivateIPv4(ip: string): boolean { + // 10.0.0.0/8 + if (ip.startsWith("10.")) return true; + // 172.16.0.0/12 (172.16.x.x - 172.31.x.x) + if (ip.startsWith("172.")) { + const parts = ip.split("."); + if (parts.length >= 2) { + const second = parseInt(parts[1], 10); + if (!Number.isNaN(second) && second >= 16 && second <= 31) return true; + } + } + // 192.168.0.0/16 + if (ip.startsWith("192.168.")) return true; + // 127.0.0.0/8 (loopback) + if (ip.startsWith("127.")) return true; + // 169.254.0.0/16 (link-local) + if (ip.startsWith("169.254.")) return true; + return false; +} + +/** + * Check if an IPv6 address is in a private/local range. + * + * Private ranges: + * - fc00::/7 (unique local addresses - fc00:: and fd00::) + * - fe80::/10 (link-local) + * - ::1 (loopback) + */ +export function isPrivateIPv6(ip: string): boolean { + const lower = ip.toLowerCase(); + // fc00::/7 (unique local) - starts with fc or fd + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + // fe80::/10 (link-local) + if (lower.startsWith("fe80")) return true; + // ::1 (loopback) + if (lower === "::1") return true; + // ::ffff: mapped IPv4 - check the IPv4 portion + if (lower.startsWith("::ffff:")) { + const ipv4Part = ip.slice("::ffff:".length); + return isPrivateIPv4(ipv4Part); + } + return false; +} + +/** + * Check if an IP address (v4 or v6) is private/local. + */ +export function isPrivateIP(ip: string): boolean { + if (!ip) return false; + const trimmed = ip.trim(); + if (trimmed.includes(":")) return isPrivateIPv6(trimmed); + return isPrivateIPv4(trimmed); +} + +/** + * Get all public (non-private) IP addresses from network interfaces. + * Useful for warning users when they bind to 0.0.0.0 on a machine with public IPs. + */ +export function getPublicIPs(): string[] { + const interfaces = os.networkInterfaces(); + const publicIPs: string[] = []; + + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.internal) continue; + if (!isPrivateIP(iface.address)) { + publicIPs.push(iface.address); + } + } + } + + return publicIPs; +} + export function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) return false; if (ip === "127.0.0.1") return true; diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index cf6d2575c..b562a7100 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { loadConfig } from "../config/config.js"; import { getResolvedLoggerSettings } from "../logging.js"; +import { getPublicIPs, isPrivateIP } from "./net.js"; export function logGatewayStartup(params: { cfg: ReturnType; @@ -37,4 +38,60 @@ export function logGatewayStartup(params: { if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); } + + // Warn if binding to 0.0.0.0 or a public IP + warnIfPublicBind(params.bindHost, params.port, params.log); +} + +/** + * Warn if the gateway is binding to an address that exposes it to the public internet. + * This is how installations end up on Shodan - users deploy on a VPS, bind to 0.0.0.0 + * or a public interface, and expose their gateway to the internet. + */ +function warnIfPublicBind( + bindAddress: string, + port: number, + log: { info: (msg: string, meta?: Record) => void }, +): void { + // 0.0.0.0 or :: binds to all interfaces + if (bindAddress === "0.0.0.0" || bindAddress === "::" || bindAddress === "") { + const publicIPs = getPublicIPs(); + if (publicIPs.length > 0) { + const warning = [ + "", + chalk.bgYellow.black(" WARNING ") + chalk.yellow(" Gateway exposed on public interface(s)"), + chalk.dim("─".repeat(60)), + ` Bind: ${chalk.cyan(`${bindAddress}:${port}`)}`, + ` Public IPs: ${chalk.red(publicIPs.join(", "))}`, + "", + chalk.dim(" This is how installations end up on Shodan."), + "", + chalk.dim(" If intentional: ensure HTTPS + strong auth"), + chalk.dim(" Recommended: use VPN/Tailscale or loopback + reverse proxy"), + chalk.dim("─".repeat(60)), + "", + ].join("\n"); + log.info(warning, { consoleMessage: warning }); + } + return; + } + + // Specific IP bind - check if it's a public IP + if (!isPrivateIP(bindAddress)) { + const warning = [ + "", + chalk.bgYellow.black(" WARNING ") + chalk.yellow(" Gateway binding to public IP"), + chalk.dim("─".repeat(60)), + ` Bind: ${chalk.red(`${bindAddress}:${port}`)}`, + "", + chalk.dim(" Binding to a public IP exposes your gateway to anyone"), + chalk.dim(" on the internet who scans your IP."), + "", + chalk.dim(" Recommended: bind to 127.0.0.1 and use a reverse proxy,"), + chalk.dim(" or use VPN/Tailscale for remote access."), + chalk.dim("─".repeat(60)), + "", + ].join("\n"); + log.info(warning, { consoleMessage: warning }); + } }