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;
|
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 = {
|
export type GatewayNodesConfig = {
|
||||||
/** Browser routing policy for node-hosted browser proxies. */
|
/** Browser routing policy for node-hosted browser proxies. */
|
||||||
browser?: {
|
browser?: {
|
||||||
@ -203,6 +235,11 @@ export type GatewayNodesConfig = {
|
|||||||
allowCommands?: string[];
|
allowCommands?: string[];
|
||||||
/** Commands to deny even if they appear in the defaults or node claims. */
|
/** Commands to deny even if they appear in the defaults or node claims. */
|
||||||
denyCommands?: string[];
|
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 = {
|
export type GatewayConfig = {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { resolveGatewayListenHosts } from "./net.js";
|
import {
|
||||||
|
resolveGatewayListenHosts,
|
||||||
|
isIpv4InCidr,
|
||||||
|
isValidCidr,
|
||||||
|
isIpInAutoApproveAllowlist,
|
||||||
|
} from "./net.js";
|
||||||
|
|
||||||
describe("resolveGatewayListenHosts", () => {
|
describe("resolveGatewayListenHosts", () => {
|
||||||
it("returns the input host when not loopback", async () => {
|
it("returns the input host when not loopback", async () => {
|
||||||
@ -26,3 +31,112 @@ describe("resolveGatewayListenHosts", () => {
|
|||||||
expect(hosts).toEqual(["127.0.0.1"]);
|
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 {
|
export function isLoopbackHost(host: string): boolean {
|
||||||
return isLoopbackAddress(host);
|
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 { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
||||||
import { loadConfig } from "../../../config/config.js";
|
import { loadConfig } from "../../../config/config.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.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 { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
@ -623,6 +628,34 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
||||||
if (device && devicePublicKey && !skipPairing) {
|
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 requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
@ -634,15 +667,22 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: reportedClientIp,
|
remoteIp: reportedClientIp,
|
||||||
silent: isLocalClient,
|
silent: shouldAutoApprove,
|
||||||
});
|
});
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
const approved = await approveDevicePairing(pairing.request.requestId);
|
const approved = await approveDevicePairing(pairing.request.requestId);
|
||||||
if (approved) {
|
if (approved) {
|
||||||
logGateway.info(
|
// Audit log with detailed information for security review
|
||||||
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
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(
|
context.broadcast(
|
||||||
"device.pair.resolved",
|
"device.pair.resolved",
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user