diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..55799eef3 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -191,6 +191,38 @@ export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; }; +export type GatewayNodesAutoApproveConfig = { + /** + * Enable auto-approval of node pairing requests. + * SECURITY: Only enable when you have additional security controls in place. + * Default: false (secure default - all pairings require manual approval). + */ + enabled?: boolean; + /** + * Roles that can be auto-approved (e.g., ["node"]). + * For security, "operator" should typically NOT be included. + * Default: [] (no roles auto-approved). + */ + roles?: string[]; + /** + * IP CIDR allowlist for auto-approval (e.g., ["10.0.0.0/8", "172.16.0.0/12"]). + * Only connections from these networks will be auto-approved. + * If undefined or empty, only localhost connections are auto-approved. + */ + ipAllowlist?: string[]; + /** + * Require valid gateway token for auto-approval. + * SECURITY: Should almost always be true. + * Default: true. + */ + requireToken?: boolean; + /** + * Log all auto-approved pairings for audit trail. + * Default: true. + */ + auditLog?: boolean; +}; + export type GatewayNodesConfig = { /** Browser routing policy for node-hosted browser proxies. */ browser?: { @@ -203,6 +235,11 @@ export type GatewayNodesConfig = { allowCommands?: string[]; /** Commands to deny even if they appear in the defaults or node claims. */ denyCommands?: string[]; + /** + * Auto-approve configuration for node pairing. + * Allows trusted nodes (e.g., in Kubernetes) to connect without manual approval. + */ + autoApprove?: GatewayNodesAutoApproveConfig; }; export type GatewayConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..e1f4beb9b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -439,6 +439,16 @@ export const MoltbotSchema = z .optional(), allowCommands: z.array(z.string()).optional(), denyCommands: z.array(z.string()).optional(), + autoApprove: z + .object({ + enabled: z.boolean().optional(), + roles: z.array(z.string()).optional(), + ipAllowlist: z.array(z.string()).optional(), + requireToken: z.boolean().optional(), + auditLog: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 46c426d63..9f0fd0dbe 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { resolveGatewayListenHosts } from "./net.js"; +import { + resolveGatewayListenHosts, + isIpv4InCidr, + isValidCidr, + isIpInAutoApproveAllowlist, +} from "./net.js"; describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { @@ -26,3 +31,118 @@ describe("resolveGatewayListenHosts", () => { expect(hosts).toEqual(["127.0.0.1"]); }); }); + +describe("isIpv4InCidr", () => { + it("returns true for IP in /8 CIDR range", () => { + expect(isIpv4InCidr("10.0.1.5", "10.0.0.0/8")).toBe(true); + expect(isIpv4InCidr("10.255.255.255", "10.0.0.0/8")).toBe(true); + }); + + it("returns false for IP outside /8 CIDR range", () => { + expect(isIpv4InCidr("11.0.0.1", "10.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("192.168.1.1", "10.0.0.0/8")).toBe(false); + }); + + it("returns true for IP in /16 CIDR range", () => { + expect(isIpv4InCidr("172.16.0.1", "172.16.0.0/12")).toBe(true); + expect(isIpv4InCidr("172.31.255.255", "172.16.0.0/12")).toBe(true); + }); + + it("returns false for IP outside /16 CIDR range", () => { + expect(isIpv4InCidr("172.32.0.1", "172.16.0.0/12")).toBe(false); + }); + + it("returns true for IP in /24 CIDR range", () => { + expect(isIpv4InCidr("192.168.1.1", "192.168.1.0/24")).toBe(true); + expect(isIpv4InCidr("192.168.1.254", "192.168.1.0/24")).toBe(true); + }); + + it("returns false for IP outside /24 CIDR range", () => { + expect(isIpv4InCidr("192.168.2.1", "192.168.1.0/24")).toBe(false); + }); + + it("returns true for /32 exact match", () => { + expect(isIpv4InCidr("192.168.1.1", "192.168.1.1/32")).toBe(true); + expect(isIpv4InCidr("192.168.1.2", "192.168.1.1/32")).toBe(false); + }); + + it("returns true for /0 (any IP)", () => { + expect(isIpv4InCidr("1.2.3.4", "0.0.0.0/0")).toBe(true); + expect(isIpv4InCidr("255.255.255.255", "0.0.0.0/0")).toBe(true); + }); + + it("returns false for invalid CIDR", () => { + expect(isIpv4InCidr("10.0.0.1", "invalid")).toBe(false); + expect(isIpv4InCidr("10.0.0.1", "10.0.0.0")).toBe(false); + }); + + it("returns false for invalid IPv4 input", () => { + expect(isIpv4InCidr("999.0.0.1", "10.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("10.0.0.1", "999.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("10.0.0.256", "10.0.0.0/8")).toBe(false); + }); +}); + +describe("isValidCidr", () => { + it("returns true for valid CIDR notation", () => { + expect(isValidCidr("10.0.0.0/8")).toBe(true); + expect(isValidCidr("172.16.0.0/12")).toBe(true); + expect(isValidCidr("192.168.1.0/24")).toBe(true); + expect(isValidCidr("192.168.1.1/32")).toBe(true); + expect(isValidCidr("0.0.0.0/0")).toBe(true); + }); + + it("returns false for invalid CIDR notation", () => { + expect(isValidCidr("10.0.0.0")).toBe(false); + expect(isValidCidr("10.0.0.0/")).toBe(false); + expect(isValidCidr("10.0.0.0/33")).toBe(false); + expect(isValidCidr("10.0.0.0/-1")).toBe(false); + expect(isValidCidr("10.0.0/8")).toBe(false); + expect(isValidCidr("256.0.0.0/8")).toBe(false); + expect(isValidCidr("invalid")).toBe(false); + expect(isValidCidr("")).toBe(false); + }); +}); + +describe("isIpInAutoApproveAllowlist", () => { + it("allows localhost when no allowlist is provided", () => { + expect(isIpInAutoApproveAllowlist("127.0.0.1", undefined)).toBe(true); + expect(isIpInAutoApproveAllowlist("127.0.0.1", [])).toBe(true); + expect(isIpInAutoApproveAllowlist("::1", undefined)).toBe(true); + }); + + it("denies non-localhost when no allowlist is provided", () => { + expect(isIpInAutoApproveAllowlist("10.0.0.1", undefined)).toBe(false); + expect(isIpInAutoApproveAllowlist("192.168.1.1", [])).toBe(false); + }); + + it("allows IP in CIDR allowlist", () => { + const allowlist = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + expect(isIpInAutoApproveAllowlist("10.0.1.5", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("172.20.0.1", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("192.168.100.50", allowlist)).toBe(true); + }); + + it("denies IP outside CIDR allowlist", () => { + const allowlist = ["10.0.0.0/8"]; + expect(isIpInAutoApproveAllowlist("192.168.1.1", allowlist)).toBe(false); + expect(isIpInAutoApproveAllowlist("8.8.8.8", allowlist)).toBe(false); + }); + + it("handles IPv4-mapped IPv6 addresses", () => { + const allowlist = ["10.0.0.0/8"]; + expect(isIpInAutoApproveAllowlist("::ffff:10.0.1.5", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("::ffff:192.168.1.1", allowlist)).toBe(false); + }); + + it("returns false for undefined/empty IP", () => { + expect(isIpInAutoApproveAllowlist(undefined, ["10.0.0.0/8"])).toBe(false); + expect(isIpInAutoApproveAllowlist("", ["10.0.0.0/8"])).toBe(false); + }); + + it("handles explicit localhost entries in allowlist", () => { + expect(isIpInAutoApproveAllowlist("127.0.0.1", ["127.0.0.1"])).toBe(true); + expect(isIpInAutoApproveAllowlist("::1", ["::1"])).toBe(true); + expect(isIpInAutoApproveAllowlist("127.0.0.1", ["localhost"])).toBe(true); + }); +}); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 6702e0e8b..1f9de6400 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -179,3 +179,93 @@ function isValidIPv4(host: string): boolean { export function isLoopbackHost(host: string): boolean { return isLoopbackAddress(host); } + +/** + * Convert an IPv4 address string to a 32-bit unsigned integer. + */ +function ipv4ToNumber(ip: string): number { + const parts = ip.split("."); + if (parts.length !== 4) return 0; + return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +} + +/** + * Check if an IPv4 address is within a CIDR range. + * + * @param ip - The IPv4 address to check (e.g., "10.0.1.5") + * @param cidr - The CIDR range (e.g., "10.0.0.0/8") + * @returns True if the IP is within the CIDR range + */ +export function isIpv4InCidr(ip: string, cidr: string): boolean { + if (!isValidIPv4(ip)) return false; + if (!isValidCidr(cidr)) return false; + const [range, bitsStr] = cidr.split("/"); + if (!range || !bitsStr) return false; + const bits = parseInt(bitsStr, 10); + + const ipNum = ipv4ToNumber(ip); + const rangeNum = ipv4ToNumber(range); + + // Create a mask with `bits` leading 1s + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + + return (ipNum & mask) === (rangeNum & mask); +} + +/** + * Validate if a string is a valid CIDR notation. + * + * @param cidr - The CIDR string to validate (e.g., "10.0.0.0/8") + * @returns True if valid CIDR format + */ +export function isValidCidr(cidr: string): boolean { + const [range, bitsStr] = cidr.split("/"); + if (!range || !bitsStr) return false; + + // Validate the bits portion + const bits = parseInt(bitsStr, 10); + if (Number.isNaN(bits) || bits < 0 || bits > 32 || bitsStr !== String(bits)) { + return false; + } + + // Validate the IP portion + const parts = range.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + const n = parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }); +} + +/** + * Check if an IP address should be auto-approved based on allowlist. + * + * Security-focused: If no allowlist is provided, only localhost is allowed. + * + * @param ip - The IP address to check + * @param allowlist - Array of CIDR ranges, or undefined/empty for localhost-only + * @returns True if the IP should be auto-approved + */ +export function isIpInAutoApproveAllowlist( + ip: string | undefined, + allowlist: string[] | undefined, +): boolean { + if (!ip) return false; + + // Normalize IPv4-mapped IPv6 addresses + const normalizedIp = ip.startsWith("::ffff:") ? ip.slice("::ffff:".length) : ip; + + // If no allowlist, only allow localhost + if (!allowlist || allowlist.length === 0) { + return isLoopbackAddress(normalizedIp); + } + + // Check if the IP matches any CIDR in the allowlist + return allowlist.some((cidr) => { + // Allow explicit localhost entries + if (cidr === "127.0.0.1" || cidr === "::1" || cidr === "localhost") { + return isLoopbackAddress(normalizedIp); + } + return isIpv4InCidr(normalizedIp, cidr); + }); +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..5f6053f7f 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -26,7 +26,12 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; +import { + isLoopbackAddress, + isTrustedProxyAddress, + resolveGatewayClientIp, + isIpInAutoApproveAllowlist, +} from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -621,8 +626,37 @@ export function attachGatewayWsMessageHandler(params: { return; } + const tokenAuthOk = authMethod === "token" || authMethod === "device-token"; const skipPairing = allowControlUiBypass && hasSharedAuth; if (device && devicePublicKey && !skipPairing) { + // Auto-approve logic with security checks + const autoApproveConfig = configSnapshot.gateway?.nodes?.autoApprove; + const shouldAutoApprove = (() => { + // Always auto-approve localhost (backward compatibility) + if (isLocalClient) return true; + + // Check if auto-approve is enabled + if (!autoApproveConfig?.enabled) return false; + + // Check role is in allowed list + const allowedRoles = autoApproveConfig.roles ?? []; + if (!allowedRoles.includes(role)) return false; + + // Check IP is in allowlist (if configured) + if (!isIpInAutoApproveAllowlist(reportedClientIp, autoApproveConfig.ipAllowlist)) { + return false; + } + + // Check token is valid (if required, which is the default) + if (autoApproveConfig.requireToken !== false && !tokenAuthOk) return false; + + return true; + })(); + + // Audit log for auto-approvals (when not localhost) + const shouldAuditLog = + shouldAutoApprove && !isLocalClient && autoApproveConfig?.auditLog !== false; + const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, @@ -634,15 +668,22 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: reportedClientIp, - silent: isLocalClient, + silent: shouldAutoApprove, }); const context = buildRequestContext(); if (pairing.request.silent === true) { const approved = await approveDevicePairing(pairing.request.requestId); if (approved) { - logGateway.info( - `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, - ); + // Audit log with detailed information for security review + if (shouldAuditLog) { + logGateway.info( + `auto-approve node pairing: device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"} ip=${reportedClientIp ?? "unknown"} client=${connectParams.client.id} displayName=${connectParams.client.displayName ?? "unknown"}`, + ); + } else { + logGateway.info( + `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, + ); + } context.broadcast( "device.pair.resolved", {