feat(gateway): warn on public IP binding to prevent accidental exposure

Add warning when gateway binds to public IP addresses to prevent users
from accidentally exposing their gateway to the internet (Shodan risk).

Changes:
- Add isPrivateIPv4/v6/IP() and getPublicIPs() utilities to net.ts
- Add warnIfPublicBind() to server-startup-log.ts for startup warning
- Enhance doctor-security.ts to detect and warn about public IP exposure

The warning appears when:
- Binding to 0.0.0.0/:: on a machine with public IPs
- Binding directly to a public IP address

Recommends VPN/Tailscale or loopback + reverse proxy for remote access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anthony 2026-01-30 05:20:16 -05:00
parent 6af205a13a
commit 2740ec5393
3 changed files with 159 additions and 1 deletions

View File

@ -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: {

View File

@ -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;

View File

@ -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<typeof loadConfig>;
@ -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<string, unknown>) => 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 });
}
}