feat(gateway): auto-approve trusted CIDR node pairing (#61004) (thanks @sahilsatralkar)

This commit is contained in:
Peter Steinberger
2026-04-25 06:35:31 +01:00
parent 6c1d4414d9
commit f44759073b
19 changed files with 627 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar.
- Browser/config: support per-profile `browser.profiles.<name>.headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu.
- Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc.
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.

View File

@@ -1,4 +1,4 @@
d885c14dea2c361123a97a0f6c854f6dbae8592f39daa211173ef7f1fe7d554a config-baseline.json
c991bb527d8efffb5c9a2c5e502113260a2873923d469289c82f7029257fddaf config-baseline.core.json
3b8ff208a31b04ea61391182444bd744357577872eac279136bbc284c3dc064a config-baseline.json
4dfeadeb814fb205f5a17d797cbbe3c07685009821fe8dbf8771ea428ed5b4dd config-baseline.core.json
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json
0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json

View File

@@ -104,6 +104,28 @@ existing approval as-is and creates a fresh pending upgrade request. Use
`openclaw devices list` to compare the currently approved access with the newly
requested access before you approve.
### Optional trusted-CIDR node auto-approve
Device pairing remains manual by default. For tightly controlled node networks,
you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
This only applies to fresh `role: node` pairing requests with no requested
scopes. Operator, browser, Control UI, and WebChat clients still require manual
approval. Role, scope, metadata, and public-key changes still require manual
approval.
### Node pairing state storage
Stored under `~/.openclaw/devices/`:

View File

@@ -297,6 +297,14 @@ See [Plugins](/tools/plugin).
trustedProxies: ["10.0.0.1"],
// Optional. Default false.
allowRealIpFallback: false,
nodes: {
pairing: {
// Optional. Default unset/disabled.
autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"],
},
allowCommands: ["canvas.navigate"],
denyCommands: ["system.run"],
},
tools: {
// Additional /tools/invoke HTTP denies
deny: ["browser"],
@@ -359,6 +367,8 @@ See [Plugins](/tools/plugin).
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and allowlist evaluation.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.

View File

@@ -115,6 +115,34 @@ The macOS app can optionally attempt a **silent approval** when:
If silent approval fails, it falls back to the normal “Approve/Reject” prompt.
## Trusted-CIDR device auto-approval
WS device pairing for `role: node` remains manual by default. For private
node networks where the Gateway already trusts the network path, operators can
opt in with explicit CIDRs or exact IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
Security boundary:
- Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset.
- No blanket LAN or private-network auto-approve mode exists.
- Only fresh `role: node` device pairing with no requested scopes is eligible.
- Operator, browser, Control UI, and WebChat clients stay manual.
- Role, scope, metadata, and public-key upgrades stay manual.
- Same-host loopback trusted-proxy header paths are not eligible because that
path can be spoofed by local callers.
## Metadata-upgrade auto-approval
When an already paired device reconnects with only non-sensitive metadata

View File

@@ -111,6 +111,7 @@ Use this as the quick model when triaging risk:
| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" |
| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" |
| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" |
| `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" |
## Not vulnerabilities by design
@@ -133,6 +134,12 @@ a real boundary bypass is demonstrated:
approval layer for `system.run`, when the real execution boundary is still
the gateway's global node command policy plus the node's own exec
approvals.
- Reports that treat configured `gateway.nodes.pairing.autoApproveCidrs` as a
vulnerability by itself. This setting is disabled by default, requires
explicit CIDR/IP entries, only applies to first-time `role: node` pairing with
no requested scopes, and does not auto-approve operator/browser/Control UI,
WebChat, role upgrades, scope upgrades, metadata changes, public-key changes,
or same-host loopback trusted-proxy header paths.
- "Missing per-user authorization" findings that treat `sessionKey` as an
auth token.
@@ -353,6 +360,12 @@ gateway:
When `trustedProxies` is configured, the Gateway uses `X-Forwarded-For` to determine the client IP. `X-Real-IP` is ignored by default unless `gateway.allowRealIpFallback: true` is explicitly set.
Trusted proxy headers do not make node device pairing automatically trusted.
`gateway.nodes.pairing.autoApproveCidrs` is a separate, disabled-by-default
operator policy. Even when enabled, loopback-source trusted-proxy header paths
are excluded from node auto-approval because local callers can forge those
headers.
Good reverse proxy behavior (overwrite incoming forwarding headers):
```nginx

View File

@@ -117,6 +117,25 @@ openclaw devices reject <requestId>
Pairing details: [Pairing](/channels/pairing).
Optional: if the Android node always connects from a tightly controlled subnet,
you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
This is disabled by default. It applies only to fresh `role: node` pairing with
no requested scopes. Operator/browser pairing and any role, scope, metadata, or
public-key change still require manual approval.
### 5) Verify the node is connected
- Via nodes status:

View File

@@ -44,6 +44,25 @@ If the app retries pairing with changed auth details (role/scopes/public key),
the previous pending request is superseded and a new `requestId` is created.
Run `openclaw devices list` again before approval.
Optional: if the iOS node always connects from a tightly controlled subnet, you
can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
This is disabled by default. It applies only to fresh `role: node` pairing with
no requested scopes. Operator/browser pairing and any role, scope, metadata, or
public-key change still require manual approval.
4. Verify connection:
```bash

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("gateway node pairing auto-approve config", () => {
it("keeps CIDR auto-approval disabled when unset", () => {
const result = validateConfigObject({
gateway: {
nodes: {},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.config.gateway?.nodes?.pairing?.autoApproveCidrs).toBeUndefined();
}
});
it.each([
{ name: "IPv4 CIDR", value: ["192.168.1.0/24"] },
{ name: "IPv6 CIDR", value: ["fd00:1234:5678::/64"] },
{ name: "exact IP", value: ["192.168.1.42"] },
{ name: "empty array", value: [] },
])("accepts $name entries", ({ value }) => {
const result = validateConfigObject({
gateway: {
nodes: {
pairing: {
autoApproveCidrs: value,
},
},
},
});
expect(result.ok).toBe(true);
});
it("rejects non-array autoApproveCidrs shape", () => {
const result = validateConfigObject({
gateway: {
nodes: {
pairing: {
autoApproveCidrs: "192.168.1.0/24",
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some((issue) => issue.path === "gateway.nodes.pairing.autoApproveCidrs"),
).toBe(true);
}
});
it("rejects non-string autoApproveCidrs entries", () => {
const result = validateConfigObject({
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24", 1234],
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some((issue) =>
issue.path.startsWith("gateway.nodes.pairing.autoApproveCidrs"),
),
).toBe(true);
}
});
});

View File

@@ -22040,6 +22040,24 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
additionalProperties: false,
},
pairing: {
type: "object",
properties: {
autoApproveCidrs: {
type: "array",
items: {
type: "string",
},
title: "Gateway Node Pairing Auto-Approve CIDRs",
description:
"Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.",
},
},
additionalProperties: false,
title: "Gateway Node Pairing",
description:
"Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.",
},
allowCommands: {
type: "array",
items: {
@@ -24880,6 +24898,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Pin browser routing to a specific node id or name (optional).",
tags: ["network"],
},
"gateway.nodes.pairing": {
label: "Gateway Node Pairing",
help: "Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.",
tags: ["network"],
},
"gateway.nodes.pairing.autoApproveCidrs": {
label: "Gateway Node Pairing Auto-Approve CIDRs",
help: "Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.",
tags: ["security", "access", "network", "advanced"],
},
"gateway.nodes.allowCommands": {
label: "Gateway Node Allowlist (Extra Commands)",
help: "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.",

View File

@@ -468,6 +468,10 @@ export const FIELD_HELP: Record<string, string> = {
"gateway.nodes.browser.mode":
'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).',
"gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).",
"gateway.nodes.pairing":
"Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.",
"gateway.nodes.pairing.autoApproveCidrs":
"Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.",
"gateway.nodes.allowCommands":
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.",
"gateway.nodes.denyCommands":

View File

@@ -321,6 +321,8 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.reload.deferralTimeoutMs": "Restart Deferral Timeout (ms)",
"gateway.nodes.browser.mode": "Gateway Node Browser Mode",
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
"gateway.nodes.pairing": "Gateway Node Pairing",
"gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs",
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
"gateway.nodes.denyCommands": "Gateway Node Denylist",
"gateway.webchat.chatHistoryMaxChars": "WebChat History Max Chars",

View File

@@ -53,6 +53,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
],
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
"gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"],
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
};

View File

@@ -363,6 +363,15 @@ export type GatewayPushConfig = {
apns?: GatewayPushApnsConfig;
};
export type GatewayNodePairingConfig = {
/**
* Opt-in CIDR/IP allowlist for auto-approving first-time node-role pairing.
* Only applies to fresh node pairing requests with no requested scopes.
* Default: unset/disabled.
*/
autoApproveCidrs?: string[];
};
export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */
browser?: {
@@ -371,6 +380,8 @@ export type GatewayNodesConfig = {
/** Pin to a specific node id/name (optional). */
node?: string;
};
/** Pairing policy for node-role gateway clients. */
pairing?: GatewayNodePairingConfig;
/** Additional node.invoke commands to allow on the gateway. */
allowCommands?: string[];
/** Commands to deny even if they appear in the defaults or node claims. */

View File

@@ -895,6 +895,12 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
pairing: z
.object({
autoApproveCidrs: z.array(z.string()).optional(),
})
.strict()
.optional(),
allowCommands: z.array(z.string()).optional(),
denyCommands: z.array(z.string()).optional(),
})

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
resolveNodePairingClientIpSource,
shouldAutoApproveNodePairingFromTrustedCidrs,
type NodePairingAutoApproveReason,
} from "./node-pairing-auto-approve.js";
const BASE_PARAMS = {
existingPairedDevice: false,
role: "node",
reason: "not-paired" as NodePairingAutoApproveReason,
scopes: [],
hasBrowserOriginHeader: false,
isControlUi: false,
isWebchat: false,
reportedClientIpSource: "direct" as const,
reportedClientIp: "192.168.1.42",
autoApproveCidrs: ["192.168.1.0/24"],
};
describe("resolveNodePairingClientIpSource", () => {
it.each([
{
name: "direct address",
params: {
reportedClientIp: "192.168.1.42",
hasProxyHeaders: false,
remoteIsTrustedProxy: false,
remoteIsLoopback: false,
},
expected: "direct",
},
{
name: "trusted proxy",
params: {
reportedClientIp: "192.168.1.42",
hasProxyHeaders: true,
remoteIsTrustedProxy: true,
remoteIsLoopback: false,
},
expected: "trusted-proxy",
},
{
name: "loopback trusted proxy",
params: {
reportedClientIp: "192.168.1.42",
hasProxyHeaders: true,
remoteIsTrustedProxy: true,
remoteIsLoopback: true,
},
expected: "loopback-trusted-proxy",
},
{
name: "no reported client IP",
params: {
reportedClientIp: undefined,
hasProxyHeaders: true,
remoteIsTrustedProxy: true,
remoteIsLoopback: false,
},
expected: "none",
},
] as const)("$name", ({ params, expected }) => {
expect(resolveNodePairingClientIpSource(params)).toBe(expected);
});
});
describe("shouldAutoApproveNodePairingFromTrustedCidrs", () => {
it("is disabled by default when no CIDRs are configured", () => {
expect(
shouldAutoApproveNodePairingFromTrustedCidrs({
...BASE_PARAMS,
autoApproveCidrs: undefined,
}),
).toBe(false);
});
it("accepts first-time node pairing from a matching direct IPv4 CIDR", () => {
expect(shouldAutoApproveNodePairingFromTrustedCidrs(BASE_PARAMS)).toBe(true);
});
it("accepts first-time node pairing from an exact IP entry", () => {
expect(
shouldAutoApproveNodePairingFromTrustedCidrs({
...BASE_PARAMS,
autoApproveCidrs: ["192.168.1.42"],
}),
).toBe(true);
});
it("accepts first-time node pairing from a matching IPv6 CIDR via non-loopback trusted proxy", () => {
expect(
shouldAutoApproveNodePairingFromTrustedCidrs({
...BASE_PARAMS,
reportedClientIpSource: "trusted-proxy",
reportedClientIp: "fd00:1234:5678::9",
autoApproveCidrs: ["fd00:1234:5678::/64"],
}),
).toBe(true);
});
it.each([
{
name: "existing paired device",
patch: { existingPairedDevice: true },
},
{
name: "operator role",
patch: { role: "operator" },
},
{
name: "non-matching CIDR",
patch: { reportedClientIp: "192.168.2.42" },
},
{
name: "requested scopes",
patch: { scopes: ["operator.read"] },
},
{
name: "browser origin",
patch: { hasBrowserOriginHeader: true },
},
{
name: "Control UI client",
patch: { isControlUi: true },
},
{
name: "WebChat client",
patch: { isWebchat: true },
},
{
name: "loopback trusted proxy",
patch: { reportedClientIpSource: "loopback-trusted-proxy" as const },
},
{
name: "missing reported client IP",
patch: { reportedClientIpSource: "none" as const, reportedClientIp: undefined },
},
{
name: "invalid CIDR config",
patch: { autoApproveCidrs: ["invalid/24"] },
},
])("rejects $name", ({ patch }) => {
expect(shouldAutoApproveNodePairingFromTrustedCidrs({ ...BASE_PARAMS, ...patch })).toBe(false);
});
it.each(["role-upgrade", "scope-upgrade", "metadata-upgrade"] as const)(
"rejects %s requests",
(reason) => {
expect(
shouldAutoApproveNodePairingFromTrustedCidrs({
...BASE_PARAMS,
reason,
}),
).toBe(false);
},
);
});

View File

@@ -0,0 +1,75 @@
import { isTrustedProxyAddress } from "./net.js";
export type NodePairingAutoApproveReason =
| "not-paired"
| "role-upgrade"
| "scope-upgrade"
| "metadata-upgrade";
export type NodePairingAutoApproveClientIpSource =
| "direct"
| "trusted-proxy"
| "loopback-trusted-proxy"
| "none";
export function resolveNodePairingClientIpSource(params: {
reportedClientIp?: string;
hasProxyHeaders: boolean;
remoteIsTrustedProxy: boolean;
remoteIsLoopback: boolean;
}): NodePairingAutoApproveClientIpSource {
if (!params.reportedClientIp) {
return "none";
}
if (!params.hasProxyHeaders || !params.remoteIsTrustedProxy) {
return "direct";
}
return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy";
}
export function shouldAutoApproveNodePairingFromTrustedCidrs(params: {
existingPairedDevice: boolean;
role: string;
reason: NodePairingAutoApproveReason;
scopes: readonly string[];
hasBrowserOriginHeader: boolean;
isControlUi: boolean;
isWebchat: boolean;
reportedClientIpSource: NodePairingAutoApproveClientIpSource;
reportedClientIp?: string;
autoApproveCidrs?: readonly string[];
}): boolean {
if (params.existingPairedDevice) {
return false;
}
if (params.role !== "node") {
return false;
}
if (params.reason !== "not-paired") {
return false;
}
if (params.scopes.length > 0) {
return false;
}
if (params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) {
return false;
}
if (
params.reportedClientIpSource === "none" ||
params.reportedClientIpSource === "loopback-trusted-proxy"
) {
return false;
}
if (!params.reportedClientIp) {
return false;
}
const autoApproveCidrs = params.autoApproveCidrs
?.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (!autoApproveCidrs || autoApproveCidrs.length === 0) {
return false;
}
return isTrustedProxyAddress(params.reportedClientIp, autoApproveCidrs);
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { writeConfigFile } from "../config/config.js";
import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { loadDeviceIdentity } from "./device-authz.test-helpers.js";
import { pickPrimaryLanIPv4 } from "./net.js";
import {
connectReq,
installGatewayTestHooks,
startServer,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const TOKEN = "secret";
const NODE_CLIENT = {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
};
async function openLanGatewayWs(params: { host: string; port: number }): Promise<WebSocket> {
const ws = new WebSocket(`ws://${params.host}:${params.port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
const cleanup = () => {
clearTimeout(timer);
ws.off("open", onOpen);
ws.off("error", onError);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
ws.once("open", onOpen);
ws.once("error", onError);
});
return ws;
}
describe("gateway trusted CIDR node pairing auto-approve", () => {
test("stays disabled by default for a direct non-loopback node", async () => {
const lanIp = pickPrimaryLanIPv4();
if (!lanIp) {
return;
}
const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false });
let ws: WebSocket | undefined;
try {
const loaded = loadDeviceIdentity("trusted-cidr-default-off");
ws = await openLanGatewayWs({ host: lanIp, port: started.port });
const res = await connectReq(ws, {
token: TOKEN,
role: "node",
scopes: [],
client: NODE_CLIENT,
deviceIdentityPath: loaded.identityPath,
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
const pending = (await listDevicePairing()).pending.filter(
(entry) => entry.deviceId === loaded.identity.deviceId,
);
expect(pending).toHaveLength(1);
expect(pending[0]?.silent).toBe(false);
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
} finally {
ws?.close();
await started.server.close();
started.envSnapshot.restore();
}
});
test("auto-approves first-time node pairing from a matching direct non-loopback CIDR", async () => {
const lanIp = pickPrimaryLanIPv4();
if (!lanIp) {
return;
}
await writeConfigFile({
gateway: {
nodes: {
pairing: {
autoApproveCidrs: [`${lanIp}/32`],
},
},
},
});
const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false });
let ws: WebSocket | undefined;
try {
const loaded = loadDeviceIdentity("trusted-cidr-direct-lan-auto-approve");
ws = await openLanGatewayWs({ host: lanIp, port: started.port });
const res = await connectReq(ws, {
token: TOKEN,
role: "node",
scopes: [],
client: NODE_CLIENT,
deviceIdentityPath: loaded.identityPath,
});
expect(res.ok).toBe(true);
expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok");
const pending = (await listDevicePairing()).pending.filter(
(entry) => entry.deviceId === loaded.identity.deviceId,
);
expect(pending).toHaveLength(0);
const paired = await getPairedDevice(loaded.identity.deviceId);
expect(paired?.role).toBe("node");
expect(paired?.approvedScopes ?? []).toEqual([]);
} finally {
ws?.close();
await started.server.close();
started.envSnapshot.restore();
}
});
});

View File

@@ -67,6 +67,10 @@ import {
resolveClientIp,
} from "../../net.js";
import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js";
import {
resolveNodePairingClientIpSource,
shouldAutoApproveNodePairingFromTrustedCidrs,
} from "../../node-pairing-auto-approve.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import {
buildPairingConnectCloseReason,
@@ -279,6 +283,12 @@ export function attachGatewayWsMessageHandler(params: {
: clientIp && !isLoopbackAddress(clientIp)
? clientIp
: undefined;
const reportedClientIpSource = resolveNodePairingClientIpSource({
reportedClientIp,
hasProxyHeaders,
remoteIsTrustedProxy,
remoteIsLoopback: isLoopbackAddress(remoteAddr),
});
if (hasUntrustedProxyHeaders) {
logWsControl.warn(
@@ -923,6 +933,20 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs(
{
existingPairedDevice: Boolean(existingPairedDevice),
role,
reason,
scopes,
hasBrowserOriginHeader,
isControlUi,
isWebchat,
reportedClientIpSource,
reportedClientIp,
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs,
},
);
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
@@ -944,7 +968,9 @@ export function attachGatewayWsMessageHandler(params: {
silent:
reason === "scope-upgrade"
? false
: allowSilentLocalPairing || allowSilentBootstrapPairing,
: allowSilentLocalPairing ||
allowSilentBootstrapPairing ||
allowSilentTrustedCidrsNodePairing,
});
const context = buildRequestContext();
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;