From f44759073b3cf2937847660e4ee12d220627b207 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:35:31 +0100 Subject: [PATCH] feat(gateway): auto-approve trusted CIDR node pairing (#61004) (thanks @sahilsatralkar) --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/channels/pairing.md | 22 +++ docs/gateway/configuration-reference.md | 10 ++ docs/gateway/pairing.md | 28 ++++ docs/gateway/security/index.md | 13 ++ docs/platforms/android.md | 19 +++ docs/platforms/ios.md | 19 +++ ....gateway-node-pairing-auto-approve.test.ts | 76 +++++++++ src/config/schema.base.generated.ts | 28 ++++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/schema.tags.ts | 1 + src/config/types.gateway.ts | 11 ++ src/config/zod-schema.ts | 6 + src/gateway/node-pairing-auto-approve.test.ts | 158 ++++++++++++++++++ src/gateway/node-pairing-auto-approve.ts | 75 +++++++++ .../server.node-pairing-auto-approve.test.ts | 125 ++++++++++++++ .../server/ws-connection/message-handler.ts | 28 +++- 19 files changed, 627 insertions(+), 3 deletions(-) create mode 100644 src/config/config.gateway-node-pairing-auto-approve.test.ts create mode 100644 src/gateway/node-pairing-auto-approve.test.ts create mode 100644 src/gateway/node-pairing-auto-approve.ts create mode 100644 src/gateway/server.node-pairing-auto-approve.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed675222b9..9caed7a24cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 89bd8e1b70c..6e0d27250ce 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 18bcf083e78..90e5b14f8cc 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -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/`: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index f3cf6d92f31..bace1d62c56 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 3981c8c192e..4029912905f 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -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 diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c85f8b94752..9a8e07813e3 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 diff --git a/docs/platforms/android.md b/docs/platforms/android.md index da5123b1367..0ed03f8ba33 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -117,6 +117,25 @@ openclaw devices reject 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: diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 4be97ddccbc..0346e5a9466 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -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 diff --git a/src/config/config.gateway-node-pairing-auto-approve.test.ts b/src/config/config.gateway-node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..87f98bce78f --- /dev/null +++ b/src/config/config.gateway-node-pairing-auto-approve.test.ts @@ -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); + } + }); +}); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 5c40b3fc3dd..9649d1f9ff2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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`.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 0b4ecdd18d0..d632ab41bc4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -468,6 +468,10 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c16dcedc354..694e2dffa01 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -321,6 +321,8 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 2d93df16bc1..314bc8ee7c3 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -53,6 +53,7 @@ const TAG_OVERRIDES: Record = { ], "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"], }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index bfcca78b2bb..f767d1edb5e 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d183d02cff3..2cb1d00fd17 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), }) diff --git a/src/gateway/node-pairing-auto-approve.test.ts b/src/gateway/node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..9e8353b336e --- /dev/null +++ b/src/gateway/node-pairing-auto-approve.test.ts @@ -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); + }, + ); +}); diff --git a/src/gateway/node-pairing-auto-approve.ts b/src/gateway/node-pairing-auto-approve.ts new file mode 100644 index 00000000000..6f3fa1f0ede --- /dev/null +++ b/src/gateway/node-pairing-auto-approve.ts @@ -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); +} diff --git a/src/gateway/server.node-pairing-auto-approve.test.ts b/src/gateway/server.node-pairing-auto-approve.test.ts new file mode 100644 index 00000000000..22c11248af8 --- /dev/null +++ b/src/gateway/server.node-pairing-auto-approve.test.ts @@ -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 { + const ws = new WebSocket(`ws://${params.host}:${params.port}`); + trackConnectChallengeNonce(ws); + await new Promise((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(); + } + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a9733b6b624..11b8e10ab28 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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> | undefined;