Merge 2740ec5393 into 09be5d45d5
This commit is contained in:
commit
455c85348d
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user