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:
parent
cb4b3f74b5
commit
bb71224dad
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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",
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user