mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
feat(gateway): auto-approve trusted CIDR node pairing (#61004) (thanks @sahilsatralkar)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
76
src/config/config.gateway-node-pairing-auto-approve.test.ts
Normal file
76
src/config/config.gateway-node-pairing-auto-approve.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
158
src/gateway/node-pairing-auto-approve.test.ts
Normal file
158
src/gateway/node-pairing-auto-approve.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
75
src/gateway/node-pairing-auto-approve.ts
Normal file
75
src/gateway/node-pairing-auto-approve.ts
Normal 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);
|
||||
}
|
||||
125
src/gateway/server.node-pairing-auto-approve.test.ts
Normal file
125
src/gateway/server.node-pairing-auto-approve.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user