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:
parent
6af205a13a
commit
2740ec5393
@ -6,7 +6,7 @@ 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 { 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) {
|
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@ -44,6 +44,12 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
|||||||
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
||||||
|
|
||||||
if (isExposed) {
|
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) {
|
if (!hasSharedSecret) {
|
||||||
const authFixLines =
|
const authFixLines =
|
||||||
resolvedAuth.mode === "password"
|
resolvedAuth.mode === "password"
|
||||||
@ -70,6 +76,16 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
|||||||
` Ensure your auth credentials are strong and not exposed.`,
|
` 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: {
|
const warnDmPolicy = async (params: {
|
||||||
|
|||||||
@ -1,7 +1,92 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
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 {
|
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||||
if (!ip) return false;
|
if (!ip) return false;
|
||||||
if (ip === "127.0.0.1") return true;
|
if (ip === "127.0.0.1") return true;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
|||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import type { loadConfig } from "../config/config.js";
|
import type { loadConfig } from "../config/config.js";
|
||||||
import { getResolvedLoggerSettings } from "../logging.js";
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
|
import { getPublicIPs, isPrivateIP } from "./net.js";
|
||||||
|
|
||||||
export function logGatewayStartup(params: {
|
export function logGatewayStartup(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
@ -37,4 +38,60 @@ export function logGatewayStartup(params: {
|
|||||||
if (params.isNixMode) {
|
if (params.isNixMode) {
|
||||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user