Merge 55c6b8efb7 into da71eaebd2
This commit is contained in:
commit
bf47885bf2
@ -2990,6 +2990,127 @@ OPENCLAW_STATE_DIR=~/.openclaw-a \
|
|||||||
openclaw gateway --port 19001
|
openclaw gateway --port 19001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `gateway.nodes` (Node management)
|
||||||
|
|
||||||
|
Configure node-related behavior including browser proxy routing, command allowlists, and auto-approval for node pairing.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
// Browser proxy routing (for remote browser control via nodes)
|
||||||
|
browser: {
|
||||||
|
mode: "auto", // "auto" | "manual" | "off"
|
||||||
|
node: "living-room-ipad" // pin a specific node (optional)
|
||||||
|
},
|
||||||
|
// Command allowlist/denylist for node-invoked commands
|
||||||
|
allowCommands: ["system.run", "browser.*"],
|
||||||
|
denyCommands: ["system.run.sudo"],
|
||||||
|
// Auto-approval for node pairing (see below)
|
||||||
|
autoApprove: {
|
||||||
|
enabled: false,
|
||||||
|
roles: ["node"],
|
||||||
|
ipAllowlist: ["10.0.0.0/8"],
|
||||||
|
requireToken: true,
|
||||||
|
auditLog: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `gateway.nodes.autoApprove` (Auto-approval for node pairing)
|
||||||
|
|
||||||
|
Allows trusted nodes to connect without manual approval. Useful for automated deployments (e.g., Kubernetes pods, CI runners) where interactive approval is impractical.
|
||||||
|
|
||||||
|
**Security:** This feature is disabled by default and implements defense-in-depth with multiple independent checks that must all pass.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
autoApprove: {
|
||||||
|
// Master switch - must be explicitly enabled
|
||||||
|
enabled: true,
|
||||||
|
// Roles that can be auto-approved (e.g., ["node"])
|
||||||
|
// SECURITY: "operator" should typically NOT be included
|
||||||
|
roles: ["node"],
|
||||||
|
// IP CIDR allowlist - only these networks are auto-approved
|
||||||
|
// If empty/unset, only localhost (127.0.0.1, ::1) is allowed
|
||||||
|
ipAllowlist: [
|
||||||
|
"10.0.0.0/8", // Private Class A
|
||||||
|
"172.16.0.0/12", // Private Class B
|
||||||
|
"192.168.0.0/16" // Private Class C
|
||||||
|
],
|
||||||
|
// Require valid gateway token for auto-approval (default: true)
|
||||||
|
// SECURITY: Should almost always be true
|
||||||
|
requireToken: true,
|
||||||
|
// Log all auto-approved pairings for audit trail (default: true)
|
||||||
|
auditLog: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field reference:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `enabled` | `false` | Master switch for auto-approval. Secure default is off. |
|
||||||
|
| `roles` | `[]` | Roles that can be auto-approved. Empty means no roles. |
|
||||||
|
| `ipAllowlist` | `[]` | CIDR ranges for allowed IPs. Empty means localhost only. |
|
||||||
|
| `requireToken` | `true` | Require valid gateway token. Should almost always be true. |
|
||||||
|
| `auditLog` | `true` | Log auto-approvals with device ID, role, IP, and client info. |
|
||||||
|
|
||||||
|
**Security considerations:**
|
||||||
|
|
||||||
|
- **Disabled by default**: Requires explicit opt-in via `enabled: true`.
|
||||||
|
- **Role restrictions**: Only roles in the `roles` array are auto-approved. Keep `operator` out unless you have a specific need.
|
||||||
|
- **IP allowlist**: If not configured, only localhost connections are auto-approved. Use CIDR notation for network ranges.
|
||||||
|
- **Token validation**: With `requireToken: true` (default), the connecting node must present a valid gateway token.
|
||||||
|
- **Audit logging**: When `auditLog: true` (default), all non-localhost auto-approvals are logged with full context for security review.
|
||||||
|
|
||||||
|
**Example: Kubernetes cluster**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
autoApprove: {
|
||||||
|
enabled: true,
|
||||||
|
roles: ["node"],
|
||||||
|
ipAllowlist: ["10.244.0.0/16"], // Kubernetes pod CIDR
|
||||||
|
requireToken: true,
|
||||||
|
auditLog: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Local development (localhost only)**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
autoApprove: {
|
||||||
|
enabled: true,
|
||||||
|
roles: ["node"],
|
||||||
|
// No ipAllowlist = localhost only (127.0.0.1, ::1)
|
||||||
|
requireToken: false // OK for local dev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Related docs:
|
||||||
|
- [Gateway pairing](/gateway/pairing)
|
||||||
|
- [Nodes CLI](/cli/nodes)
|
||||||
|
- [Gateway security](/gateway/security)
|
||||||
|
|
||||||
### `hooks` (Gateway webhooks)
|
### `hooks` (Gateway webhooks)
|
||||||
|
|
||||||
Enable a simple HTTP webhook endpoint on the Gateway HTTP server.
|
Enable a simple HTTP webhook endpoint on the Gateway HTTP server.
|
||||||
|
|||||||
@ -64,13 +64,47 @@ Notes:
|
|||||||
`node.pair.request`.
|
`node.pair.request`.
|
||||||
- Requests may include `silent: true` as a hint for auto-approval flows.
|
- Requests may include `silent: true` as a hint for auto-approval flows.
|
||||||
|
|
||||||
## Auto-approval (macOS app)
|
## Auto-approval
|
||||||
|
|
||||||
|
Auto-approval allows trusted nodes to connect without manual intervention. There are two mechanisms:
|
||||||
|
|
||||||
|
### macOS app (SSH verification)
|
||||||
|
|
||||||
The macOS app can optionally attempt a **silent approval** when:
|
The macOS app can optionally attempt a **silent approval** when:
|
||||||
- the request is marked `silent`, and
|
- the request is marked `silent`, and
|
||||||
- the app can verify an SSH connection to the gateway host using the same user.
|
- the app can verify an SSH connection to the gateway host using the same user.
|
||||||
|
|
||||||
If silent approval fails, it falls back to the normal “Approve/Reject” prompt.
|
If silent approval fails, it falls back to the normal "Approve/Reject" prompt.
|
||||||
|
|
||||||
|
### Configuration-based auto-approval
|
||||||
|
|
||||||
|
For automated deployments (Kubernetes, CI runners, etc.), you can configure the gateway to auto-approve nodes based on role, IP address, and token validation.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
nodes: {
|
||||||
|
autoApprove: {
|
||||||
|
enabled: true,
|
||||||
|
roles: ["node"], // Only auto-approve "node" role
|
||||||
|
ipAllowlist: ["10.0.0.0/8"], // Only from private network
|
||||||
|
requireToken: true, // Must have valid gateway token
|
||||||
|
auditLog: true // Log all auto-approvals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security layers (all must pass):**
|
||||||
|
1. `enabled: true` - Feature must be explicitly enabled
|
||||||
|
2. Role must be in `roles` array
|
||||||
|
3. Client IP must match `ipAllowlist` (or be localhost if empty)
|
||||||
|
4. Valid gateway token required (when `requireToken: true`)
|
||||||
|
|
||||||
|
Localhost connections (127.0.0.1, ::1) are always auto-approved for backward compatibility.
|
||||||
|
|
||||||
|
See [gateway.nodes.autoApprove](/gateway/configuration#gatewaynodes-node-management) for the full configuration reference.
|
||||||
|
|
||||||
## Storage (local, private)
|
## Storage (local, private)
|
||||||
|
|
||||||
|
|||||||
@ -193,6 +193,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?: {
|
||||||
@ -205,6 +237,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 = {
|
||||||
|
|||||||
@ -440,6 +440,16 @@ export const OpenClawSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
allowCommands: z.array(z.string()).optional(),
|
allowCommands: z.array(z.string()).optional(),
|
||||||
denyCommands: 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()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -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,118 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -179,3 +179,93 @@ 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 {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
@ -621,8 +626,37 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenAuthOk = authMethod === "token" || authMethod === "device-token";
|
||||||
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 && !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 requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
@ -634,15 +668,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