feat(gateway): implement secure autoApproveNodes configuration

Add gateway.nodes.autoApprove configuration to allow trusted nodes to
connect without manual approval, with multiple security layers:

- Gateway token required by default (requireToken: true)
- IP CIDR allowlist for network-level restrictions
- Role restriction (only specified roles can auto-approve)
- Audit logging for all auto-approvals

Security design:
1. Disabled by default (secure default)
2. Require explicit enablement and role configuration
3. If no ipAllowlist specified, only localhost is auto-approved
4. Audit log captures device ID, role, IP, client info

Changes:
- Add GatewayNodesAutoApproveConfig type (types.gateway.ts)
- Add isIpv4InCidr, isValidCidr, isIpInAutoApproveAllowlist (net.ts)
- Modify pairing logic to check autoApprove config (message-handler.ts)
- Add comprehensive unit tests for CIDR validation

Example configuration:
{
  "gateway": {
    "nodes": {
      "autoApprove": {
        "enabled": true,
        "roles": ["node"],
        "ipAllowlist": ["10.0.0.0/8", "172.16.0.0/12"],
        "requireToken": true,
        "auditLog": true
      }
    }
  }
}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Rodrigo Uroz 2026-01-29 14:11:56 -03:00
parent cb4b3f74b5
commit bb71224dad
4 changed files with 286 additions and 6 deletions

View File

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

View File

@ -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,112 @@ 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);
});
});
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);
});
});

View File

@ -179,3 +179,92 @@ 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 {
const [range, bitsStr] = cidr.split("/");
if (!range || !bitsStr) return false;
const bits = parseInt(bitsStr, 10);
if (Number.isNaN(bits) || bits < 0 || bits > 32) return false;
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);
});
}

View File

@ -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,
@ -623,6 +628,34 @@ export function attachGatewayWsMessageHandler(params: {
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 && !authOk) 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 +667,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",
{