From ea967be0df6be8f9f3db4359ad61a91383c60f57 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 12:25:57 -0400 Subject: [PATCH] feat(gateway): add loopback locality controls (#115959) * feat(gateway): add loopback locality controls * fix(gateway): keep loopback auth delays enforced under concurrency The pending-timer cap let an attacker park cheap failures in every slot and then guess without penalty. Delays now key off a per-key deadline, so parallel guesses wait out the same escalating penalty and are still bounded by the max delay. * fix(gateway): share one loopback penalty timer per key Concurrent failures on a key now share a single timer and deadline instead of allocating one per in-flight request. Also corrects the security doc: the delay raises the cost of repeated guessing from one source, but credentials are compared before the failure response is delayed, so it is not a defense against parallel fan-out. * docs(gateway): record why loopback delay stays post-verification * docs: refresh generated docs map * docs: refresh plugin SDK API baseline * test: update loopback locality CI expectations --- docs/.generated/config-baseline.counts.json | 4 +- docs/.generated/config-baseline.sha256 | 8 +- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- docs/docs_map.md | 1 + docs/gateway/configuration-reference.md | 4 + docs/gateway/pairing.md | 31 ++++ docs/gateway/security/index.md | 1 + .../schema.help.quality.test.ts.snap | 1 + ....gateway-node-pairing-auto-approve.test.ts | 35 +++- src/config/schema.help.runtime.ts | 2 + src/config/schema.labels.ts | 1 + src/config/schema.tags.ts | 1 + src/config/schema.tiers.ts | 2 +- src/config/types.gateway.ts | 6 + src/config/zod-schema.gateway.ts | 1 + src/gateway/auth-rate-limit.test.ts | 151 +++++++++++++++++- src/gateway/auth-rate-limit.ts | 117 +++++++++++++- src/gateway/auth.test.ts | 6 + src/gateway/auth.ts | 20 +-- src/gateway/control-ui.ts | 2 +- .../ws-connection/auth-context.state.test.ts | 7 + .../server/ws-connection/auth-context.test.ts | 13 +- .../ws-connection/connect-device-pairing.ts | 1 + .../handshake-auth-helpers.test.ts | 43 +++++ .../ws-connection/handshake-auth-helpers.ts | 7 + ...essage-handler.post-connect-health.test.ts | 1 + 26 files changed, 438 insertions(+), 30 deletions(-) diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 0ee505cfc912..80ad6e74b217 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2305, + "core": 2306, "channel": 3648, - "plugin": 3583 + "plugin": 3601 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 008a9afbd82d..4e6f3ba15e33 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -4dc7458ef793bc3c483e57be2af7e5118f3de824483a4b8e6ef2afcdf7a38e0a config-baseline.json -82dbf6abbd417071aa545c3d398afbab297591b117627ff5766c1fad689b8f40 config-baseline.core.json -b3d7fdc2662b49b2e6567573852545a36a8eab13bb2111011510aa96a80a3b8b config-baseline.channel.json -99e8401cbeff42d79d84e08cdadb5b1b285e2b6734e41ab77ed2b42438aea991 config-baseline.plugin.json +62b3afb44bfa3db462541f5f792813a9dee0e6b1516b2a0ce24bd0f2b18cd783 config-baseline.json +c6c8d6001b725802d9ad29d9f62e8dbff69e8d7202504e353c38503a81aa77ac config-baseline.core.json +86d3787a38cb082b8abf238269384f4061f33f035887e2733b0367cce0841014 config-baseline.channel.json +5b9ca40e66eeee315abca6ed89a72e72af0e19ce14592659ad1301223f56bca5 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 560bad3259e9..f43e1f8b7304 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -140,7 +140,7 @@ ff35f9f74d35d37a2eb6126b57f3dc5a4d580b6222fe3a34c9368779ad32eab7 module/state-p a94c9ff59cc361b04f8731a47d722c4bc88cfabf942221369a8cb67bdcda7249 module/tool-plugin 9d6ab352913a573b226e054e1dc8c6d088493aea9954950c65585923b5b6895a module/tool-send 541df9dea799f25e83ea483d481ecebc5b91c016effab593c54d3efe3ee6517b module/web-media -c1fe90c8d833e82aa6a94885b4ec5cb54d0463ce5a3bee25ad24124ff8cc2d20 module/webhook-ingress +2d9a53e49d9092804945248efa6d03ce0099cfa7a1c9d7836782754b5e99ebb3 module/webhook-ingress af8c5e1c84ec9d365a7aa0d15eb63f0dd9e7b50526023e6212bba020c229f224 module/webhook-request-guards 1cc469eacda2818a116ab7b63847b9ddf86225e8d02e62fac48e618ba41a9852 module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/docs_map.md b/docs/docs_map.md index ed514ec557bb..591f3062b9a4 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3910,6 +3910,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: API surface (gateway protocol) - H2: Node command gating (2026.3.31+) - H2: Node event trust boundaries (2026.3.31+) + - H2: Silent local pairing - H2: SSH-verified device auto-approval (default) - H2: Auto-approval (macOS app) - H2: Trusted-CIDR device auto-approval diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 85357c2ade00..27b0e28f6fab 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -604,6 +604,9 @@ See [Plugins](/tools/plugin). allowRealIpFallback: false, nodes: { pairing: { + // Silent same-host pairing and access upgrades. Default: enabled. + // Set false to require explicit approval for every device. + autoApproveLocal: true, // Optional. Default unset/disabled. autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"], // SSH-verified auto-approval. Default: enabled (true). @@ -688,6 +691,7 @@ 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.autoApproveLocal`: silently approves pairing, role upgrades, and scope upgrades from trusted local connections (default: `true`). Set `false` to require explicit approval for every device; metadata-only reconnect refreshes remain automatic. - `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.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default). - `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. `commands.allow` is the one-time persistent enable for classified commands such as `camera.snap`, `camera.clip`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `commands.deny` removes a command even if a platform default or explicit allow would otherwise include it. Computer and mobile UI control instead rely on default-off node-local enablement plus pairing. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 4e22a482ce0c..e2dc55e2d2f8 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -140,6 +140,37 @@ sessions, and updates pairing metadata only when the device/node identity is already paired. A self-declared `client.id` value is not enough to write last-seen state. +## Silent local pairing + +The Gateway treats a loopback source address as local. This includes a client +reaching a remote loopback-only Gateway through an SSH port forward: the SSH +server terminates the connection on the Gateway host, so the Gateway sees the +forwarded connection as loopback. This is intentional because ordinary SSH +access already implies local trust, including the ability to read the shared +Gateway token. + +By default, trusted local connections silently approve first-time device +pairing plus role and scope upgrades. This keeps normal same-host and SSH +tunnel reconnects convenient. Operators using shell-less, port-forward-only +SSH keys or a multi-user Mac can require explicit approval for every device: + +```json5 +{ + gateway: { + nodes: { + pairing: { + autoApproveLocal: false, + }, + }, + }, +} +``` + +With this setting, new pairing requests, role upgrades, and scope upgrades use +the normal approval flow even when the connection is local. Metadata-only +reconnect refreshes remain automatic so routine client or OS metadata changes +do not create approval churn. + ## SSH-verified device auto-approval (default) First-time `role: node` device pairing from a private/CGNAT address is diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 032a63a607a7..4427d4fd9351 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -653,6 +653,7 @@ Trusted proxy headers do not make node device pairing automatically trusted - `g - OpenClaw's gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS there. - If the gateway itself terminates HTTPS, `gateway.http.securityHeaders.strictTransportSecurity` emits the HSTS header from OpenClaw responses. - Non-loopback Control UI deployments require `gateway.controlUi.allowedOrigins` by default; `allowedOrigins: ["*"]` is an explicit allow-all policy, not a hardened default - avoid it outside tightly controlled local testing. +- Failed authentication from loopback is never locked out, so a local CLI cannot be denied before its credentials are checked. Wrong credentials are still tracked and progressively delayed (bounded delay, one shared timer per key); successful authentication resets the failure history. This raises the cost of repeated guessing from one loopback source; it is not a defense against an attacker who can already open many parallel loopback connections, because credentials are compared before the failure response is delayed. Loopback reachability is a trust boundary in its own right - see [Node pairing](/gateway/pairing#silent-local-pairing). - Browser-origin auth failures on loopback are still rate-limited even with the general loopback exemption enabled, but the lockout key is scoped per normalized `Origin` value instead of one shared localhost bucket. - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. - Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index 3cf35529e119..4200c0c3bcc7 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -168,6 +168,7 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`] "gateway.mode", "gateway.nodes.allowSkills", "gateway.nodes.pairing.autoApproveCidrs.*", + "gateway.nodes.pairing.autoApproveLocal", "gateway.nodes.pluginTools.enabled", "gateway.port", "gateway.remote.password", diff --git a/src/config/config.gateway-node-pairing-auto-approve.test.ts b/src/config/config.gateway-node-pairing-auto-approve.test.ts index 6512847acef3..37e43829a4e7 100644 --- a/src/config/config.gateway-node-pairing-auto-approve.test.ts +++ b/src/config/config.gateway-node-pairing-auto-approve.test.ts @@ -3,7 +3,7 @@ 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", () => { + it("keeps local auto-approval implicit and CIDR auto-approval disabled when unset", () => { const result = validateConfigObject({ gateway: { nodes: {}, @@ -12,10 +12,43 @@ describe("gateway node pairing auto-approve config", () => { expect(result.ok).toBe(true); if (result.ok) { + expect(result.config.gateway?.nodes?.pairing?.autoApproveLocal).toBeUndefined(); expect(result.config.gateway?.nodes?.pairing?.autoApproveCidrs).toBeUndefined(); } }); + it.each([true, false])("accepts autoApproveLocal=%s", (autoApproveLocal) => { + const result = validateConfigObject({ + gateway: { + nodes: { + pairing: { autoApproveLocal }, + }, + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.gateway?.nodes?.pairing?.autoApproveLocal).toBe(autoApproveLocal); + } + }); + + it("rejects non-boolean autoApproveLocal shape", () => { + const result = validateConfigObject({ + gateway: { + nodes: { + pairing: { autoApproveLocal: "false" }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect( + result.issues.some((issue) => issue.path === "gateway.nodes.pairing.autoApproveLocal"), + ).toBe(true); + } + }); + it.each([ { name: "IPv4 CIDR", value: ["192.168.1.0/24"] }, { name: "IPv6 CIDR", value: ["fd00:1234:5678::/64"] }, diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index f9a321382aae..f5283f9b1b20 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -231,6 +231,8 @@ export const RUNTIME_FIELD_HELP: Record = { "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", "gateway.nodes.pairing": "Node pairing policy settings. SSH-verified auto-approval is enabled by default; CIDR auto-approval stays disabled unless explicit trusted CIDR/IP allowlists are configured.", + "gateway.nodes.pairing.autoApproveLocal": + "Silently approve trusted local pairing and access upgrades (default: true); set false to trade convenience for explicit approval of every device.", "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.pairing.sshVerify": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 092a8724ca01..008ed142f6e4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -365,6 +365,7 @@ export const FIELD_LABELS: Record = { "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.autoApproveLocal": "Gateway Node Pairing Auto-Approve Local", "gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs", "gateway.nodes.pairing.sshVerify": "Gateway Node Pairing SSH Verification", ...NODE_CAPABILITY_FIELD_LABELS, diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index a45f1f54426a..b2703f8fafc9 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -56,6 +56,7 @@ const TAG_OVERRIDES: Record = { "network", "advanced", ], + "gateway.nodes.pairing.autoApproveLocal": ["security", "access", "advanced"], "gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.sshVerify": ["security", "access", "network", "advanced"], "mcp.apps.enabled": ["security", "access", "advanced"], diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index e68ec46991ab..6d554cf31edf 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -122,7 +122,7 @@ gateway.auth.trustedProxy.allowUsers gateway.auth.trustedProxy.userHeader gatewa gateway.controlUi.allowedOrigins gateway.http.endpoints.chatCompletions.images.urlAllowlist gateway.http.endpoints.responses.files.urlAllowlist gateway.http.endpoints.responses.images.urlAllowlist gateway.mode gateway.nodes.allowSkills -gateway.nodes.pairing.autoApproveCidrs gateway.nodes.pluginTools.enabled gateway.port +gateway.nodes.pairing.autoApproveCidrs gateway.nodes.pairing.autoApproveLocal gateway.nodes.pluginTools.enabled gateway.port gateway.remote.password gateway.remote.sshTarget gateway.remote.tlsFingerprint gateway.remote.token gateway.remote.transport gateway.remote.url gateway.tailscale.mode gateway.trustedProxies hooks.allowedAgentIds hooks.enabled hooks.gmail.account hooks.gmail.label diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 0de3659b4b05..c8bc0bce9346 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -455,6 +455,12 @@ export type GatewayPushConfig = { }; export type GatewayNodePairingConfig = { + /** + * Silently approve trusted local device pairing and access upgrades. + * Set false to require explicit approval; metadata refreshes remain automatic. + * Default: true. + */ + autoApproveLocal?: boolean; /** * Opt-in CIDR/IP allowlist for auto-approving first-time node-role pairing. * Only applies to fresh node pairing requests with no requested scopes. diff --git a/src/config/zod-schema.gateway.ts b/src/config/zod-schema.gateway.ts index 8c869e909490..ef86ee661d8c 100644 --- a/src/config/zod-schema.gateway.ts +++ b/src/config/zod-schema.gateway.ts @@ -194,6 +194,7 @@ export const GatewayConfigSchema = z .optional(), pairing: z .strictObject({ + autoApproveLocal: z.boolean().optional(), autoApproveCidrs: z.array(z.string()).optional(), sshVerify: z .union([ diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts index a8798be4c87d..d31b7160ef6b 100644 --- a/src/gateway/auth-rate-limit.test.ts +++ b/src/gateway/auth-rate-limit.test.ts @@ -268,10 +268,144 @@ describe("auth rate limiter", () => { it.each(["127.0.0.1", "::1"])("exempts loopback address %s by default", (ip) => { limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); - limiter.recordFailure(ip); + for (let attempt = 0; attempt < 20; attempt += 1) { + limiter.recordFailure(ip); + } expect(limiter.check(ip).allowed).toBe(true); }); + it("escalates and caps the loopback failure delay", async () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + pruneIntervalMs: 0, + }); + const ip = "127.0.0.1"; + + const first = limiter.recordFailureAndDelay(ip); + await vi.advanceTimersByTimeAsync(249); + let settled = false; + void first.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await first; + + const second = limiter.recordFailureAndDelay(ip); + await vi.advanceTimersByTimeAsync(499); + settled = false; + void second.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await second; + + for (let attempt = 0; attempt < 100; attempt += 1) { + limiter.recordFailure(ip); + } + const capped = limiter.recordFailureAndDelay(ip); + await vi.advanceTimersByTimeAsync(4_999); + settled = false; + void capped.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await capped; + expect(limiter.check(ip).allowed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("reset clears the loopback penalty history", async () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ pruneIntervalMs: 0 }); + limiter.recordFailure("127.0.0.1"); + limiter.recordFailure("127.0.0.1"); + limiter.reset("127.0.0.1"); + + const delayed = limiter.recordFailureAndDelay("127.0.0.1"); + await vi.advanceTimersByTimeAsync(249); + let settled = false; + void delayed.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await delayed; + } finally { + vi.useRealTimers(); + } + }); + + // Regression: an earlier revision skipped the delay once a global timer cap was + // full, so an attacker could park cheap failures in every slot and then guess + // without penalty. Concurrency must never buy a faster answer than one attempt. + it("still delays loopback failures when many are already pending", async () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ pruneIntervalMs: 0 }); + const pending = Array.from({ length: 64 }, (_, index) => + limiter.recordFailureAndDelay("127.0.0.1", `scope-${index}`), + ); + + let extraSettled = false; + const extra = limiter.recordFailureAndDelay("127.0.0.1", "scope-extra").then(() => { + extraSettled = true; + }); + await Promise.resolve(); + expect(extraSettled).toBe(false); + + await vi.advanceTimersByTimeAsync(250); + await extra; + expect(extraSettled).toBe(true); + + limiter.dispose(); + await Promise.all(pending); + } finally { + vi.useRealTimers(); + } + }); + + // Parallel guesses on one key share the key's deadline instead of each starting + // a fresh short timer, so fanning out cannot outrun the escalating penalty. + it("holds concurrent failures for the same key to a shared deadline", async () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ pruneIntervalMs: 0 }); + // Escalate the key first so its penalty is well above the 250ms base. + for (let i = 0; i < 4; i += 1) { + const settled = limiter.recordFailureAndDelay("127.0.0.1", "shared"); + await vi.advanceTimersByTimeAsync(5_000); + await settled; + } + + let lateSettled = false; + const late = limiter.recordFailureAndDelay("127.0.0.1", "shared").then(() => { + lateSettled = true; + }); + await vi.advanceTimersByTimeAsync(250); + expect(lateSettled).toBe(false); + + await vi.advanceTimersByTimeAsync(5_000); + await late; + expect(lateSettled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it("rate-limits loopback when exemptLoopback is false", () => { limiter = createAuthRateLimiter({ maxAttempts: 1, @@ -387,4 +521,19 @@ describe("auth rate limiter", () => { limiter.dispose(); expect(limiter.size()).toBe(0); }); + + it("dispose settles pending loopback failure delays immediately", async () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ pruneIntervalMs: 0 }); + const pending = limiter.recordFailureAndDelay("127.0.0.1"); + + limiter.dispose(); + + await pending; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 4cd4b23aa968..889978dab918 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -10,8 +10,9 @@ * - Pure in-memory Map – no external dependencies; suitable for a single * gateway process. The Map is periodically pruned and capped to avoid * unbounded growth. - * - Loopback addresses (127.0.0.1 / ::1) are exempt by default so that local - * CLI sessions are never locked out. + * - Loopback addresses (127.0.0.1 / ::1) are exempt from denial by default so + * local CLI sessions are never locked out. Failed auth still incurs a + * bounded, escalating delay. * - The module is side-effect-free: callers create an instance via * {@link createAuthRateLimiter} and pass it where needed. */ @@ -83,6 +84,16 @@ export interface AuthRateLimiter { check(ip: string | undefined, scope?: string): RateLimitCheckResult; /** Record a failed authentication attempt for `ip`. */ recordFailure(ip: string | undefined, scope?: string): void; + /** + * Record a failed attempt and await any loopback penalty delay. + * + * Deliberately post-verification: it prices repeated guessing from one loopback + * source without ever gating a request before its credentials are checked. + * Gating earlier would stop parallel fan-out, but would also let a bad local + * peer stall the operator's own correct-credential CLI, which loopback must + * never do. Fan-out from loopback is out of scope for this limiter by design. + */ + recordFailureAndDelay(ip: string | undefined, scope?: string): Promise; /** Reset the rate-limit state for `ip` (e.g. after a successful login). */ reset(ip: string | undefined, scope?: string): void; /** Return the current number of tracked IPs (useful for diagnostics). */ @@ -102,6 +113,10 @@ const DEFAULT_WINDOW_MS = 60_000; // 1 minute const DEFAULT_LOCKOUT_MS = 300_000; // 5 minutes const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute const DEFAULT_MAX_ENTRIES = 10_000; +const LOOPBACK_FAILURE_DELAY_BASE_MS = 250; +const LOOPBACK_FAILURE_DELAY_MAX_MS = 5_000; +const LOOPBACK_FAILURE_HISTORY_LIMIT = + Math.ceil(Math.log2(LOOPBACK_FAILURE_DELAY_MAX_MS / LOOPBACK_FAILURE_DELAY_BASE_MS)) + 1; // --------------------------------------------------------------------------- // Implementation @@ -146,6 +161,15 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter const maxEntries = resolveIntegerOption(config?.maxEntries, DEFAULT_MAX_ENTRIES, { min: 1 }); const entries = new Map(); + // Penalty deadlines are per key, not per in-flight request: concurrent failures + // for one key all wait out the same deadline, so an attacker cannot buy free + // guesses by keeping timers occupied. Settlers are tracked only so dispose() + // can release waiters without stalling gateway shutdown. + const loopbackPenaltyUntil = new Map(); + const loopbackPenaltyWaiters = new Map< + string, + { deadline: number; resolvers: (() => void)[]; timer: ReturnType } + >(); let overflowLockedUntil: number | undefined; // Periodic cleanup to avoid unbounded map growth. @@ -224,9 +248,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter function recordFailure(rawIp: string | undefined, rawScope?: string): void { const { key, ip } = resolveKey(rawIp, rawScope); - if (isExempt(ip)) { - return; - } + const exempt = isExempt(ip); const now = Date.now(); let entry = entries.get(key); @@ -240,7 +262,8 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter entries.set(key, entry); } - // If currently locked, do nothing (already blocked). + // If currently locked, do nothing (already blocked). Loopback entries are + // never locked, so every failed local attempt continues to count. if (entry.lockedUntil && now < entry.lockedUntil) { return; } @@ -248,14 +271,83 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter slideWindow(entry, now); entry.attempts.push(now); - if (entry.attempts.length >= maxAttempts) { + if (exempt && entry.attempts.length > LOOPBACK_FAILURE_HISTORY_LIMIT) { + // The delay is already capped at this history length. Discard older + // timestamps so timer-cap overflow cannot grow loopback state unbounded. + entry.attempts.splice(0, entry.attempts.length - LOOPBACK_FAILURE_HISTORY_LIMIT); + } else if (!exempt && entry.attempts.length >= maxAttempts) { entry.lockedUntil = now + lockoutMs; } } + async function recordFailureAndDelay( + rawIp: string | undefined, + rawScope?: string, + ): Promise { + const { key, ip } = resolveKey(rawIp, rawScope); + recordFailure(rawIp, rawScope); + if (!isExempt(ip)) { + return; + } + + const failureCount = entries.get(key)?.attempts.length ?? 1; + const penaltyMs = Math.min( + LOOPBACK_FAILURE_DELAY_BASE_MS * 2 ** Math.min(failureCount - 1, 30), + LOOPBACK_FAILURE_DELAY_MAX_MS, + ); + // Hold the key's deadline at the furthest point any current failure earned, but + // never past one full penalty from now, so parallel guesses cannot be answered + // sooner than a serial one and cannot be queued into an unbounded wait either. + const now = Date.now(); + const deadline = Math.min( + Math.max(loopbackPenaltyUntil.get(key) ?? 0, now + penaltyMs), + now + LOOPBACK_FAILURE_DELAY_MAX_MS, + ); + loopbackPenaltyUntil.set(key, deadline); + await new Promise((resolve) => { + // One timer per key, not per request: every waiter on a key is released by the + // same deadline, so concurrent failures cost a bounded number of timers + // (at most one per distinct loopback key) instead of one per open attempt. + const existing = loopbackPenaltyWaiters.get(key); + if (existing) { + existing.resolvers.push(resolve); + if (deadline > existing.deadline) { + existing.deadline = deadline; + clearTimeout(existing.timer); + existing.timer = scheduleRelease(key, deadline); + } + return; + } + loopbackPenaltyWaiters.set(key, { + deadline, + resolvers: [resolve], + timer: scheduleRelease(key, deadline), + }); + }); + } + + function scheduleRelease(key: string, deadline: number): ReturnType { + const timer = setTimeout(() => releaseLoopbackWaiters(key), Math.max(0, deadline - Date.now())); + timer.unref?.(); + return timer; + } + + function releaseLoopbackWaiters(key: string): void { + const waiters = loopbackPenaltyWaiters.get(key); + if (!waiters) { + return; + } + loopbackPenaltyWaiters.delete(key); + clearTimeout(waiters.timer); + for (const resolve of waiters.resolvers) { + resolve(); + } + } + function reset(rawIp: string | undefined, rawScope?: string): void { const { key } = resolveKey(rawIp, rawScope); entries.delete(key); + loopbackPenaltyUntil.delete(key); } function pruneExpiredEntries(now: number): void { @@ -269,6 +361,11 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter entries.delete(key); } } + for (const [key, until] of loopbackPenaltyUntil) { + if (now >= until) { + loopbackPenaltyUntil.delete(key); + } + } } function checkOverflowLock(now: number): RateLimitCheckResult | undefined { @@ -326,8 +423,12 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter clearInterval(pruneTimer); } entries.clear(); + loopbackPenaltyUntil.clear(); overflowLockedUntil = undefined; + for (const key of loopbackPenaltyWaiters.keys()) { + releaseLoopbackWaiters(key); + } } - return { check, recordFailure, reset, size, prune, dispose }; + return { check, recordFailure, recordFailureAndDelay, reset, size, prune, dispose }; } diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 5e25a5a62459..1b8fe024128c 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -23,10 +23,16 @@ function createLimiterSpy(): AuthRateLimiter & { (_ip, _scope) => ({ allowed: true, remaining: 10, retryAfterMs: 0 }) as const, ); const recordFailure = vi.fn((_ip, _scope) => {}); + const recordFailureAndDelay = vi.fn( + async (ip, scope) => { + recordFailure(ip, scope); + }, + ); const reset = vi.fn((_ip, _scope) => {}); return { check, recordFailure, + recordFailureAndDelay, reset, size: () => 0, prune: () => {}, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 134c49cd423d..effc0bc5d1db 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -366,13 +366,13 @@ function authorizeTrustedProxyBrowserOrigin(params: { }); } -function authorizeTokenAuth(params: { +async function authorizeTokenAuth(params: { authToken?: string; connectToken?: string; limiter?: AuthRateLimiter; ip?: string; rateLimitScope: string; -}): GatewayAuthResult { +}): Promise { if (!params.authToken) { return { ok: false, reason: "token_missing_config" }; } @@ -383,20 +383,20 @@ function authorizeTokenAuth(params: { return { ok: false, reason: "token_missing" }; } if (!safeEqualSecret(params.connectToken, params.authToken)) { - params.limiter?.recordFailure(params.ip, params.rateLimitScope); + await params.limiter?.recordFailureAndDelay(params.ip, params.rateLimitScope); return { ok: false, reason: "token_mismatch" }; } params.limiter?.reset(params.ip, params.rateLimitScope); return { ok: true, method: "token" }; } -function authorizePasswordAuth(params: { +async function authorizePasswordAuth(params: { authPassword?: string; connectPassword?: string; limiter?: AuthRateLimiter; ip?: string; rateLimitScope: string; -}): GatewayAuthResult { +}): Promise { if (!params.authPassword) { return { ok: false, reason: "password_missing_config" }; } @@ -405,7 +405,7 @@ function authorizePasswordAuth(params: { return { ok: false, reason: "password_missing" }; } if (!safeEqualSecret(params.connectPassword, params.authPassword)) { - params.limiter?.recordFailure(params.ip, params.rateLimitScope); + await params.limiter?.recordFailureAndDelay(params.ip, params.rateLimitScope); return { ok: false, reason: "password_mismatch" }; } params.limiter?.reset(params.ip, params.rateLimitScope); @@ -499,7 +499,7 @@ async function authorizeGatewayConnectCore( if (rateLimitResult) { return rateLimitResult; } - return authorizePasswordAuth({ + return await authorizePasswordAuth({ authPassword: auth.password, connectPassword: connectAuth.password, limiter, @@ -549,7 +549,7 @@ async function authorizeGatewayConnectCore( } if (auth.mode === "token") { - return authorizeTokenAuth({ + return await authorizeTokenAuth({ authToken: auth.token, connectToken: connectAuth?.token, limiter, @@ -559,7 +559,7 @@ async function authorizeGatewayConnectCore( } if (auth.mode === "password") { - return authorizePasswordAuth({ + return await authorizePasswordAuth({ authPassword: auth.password, connectPassword: connectAuth?.password, limiter, @@ -568,7 +568,7 @@ async function authorizeGatewayConnectCore( }); } - limiter?.recordFailure(ip, rateLimitScope); + await limiter?.recordFailureAndDelay(ip, rateLimitScope); return { ok: false, reason: "unauthorized" }; } diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 13416545ae95..e550ca25bb07 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -335,7 +335,7 @@ async function authorizeControlUiReadRequest( opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); resolvedAuthResult = { ok: true, method: "device-token" }; } else { - opts.rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + await opts.rateLimiter?.recordFailureAndDelay(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); } } } diff --git a/src/gateway/server/ws-connection/auth-context.state.test.ts b/src/gateway/server/ws-connection/auth-context.state.test.ts index a9bfe57f6f97..74c631f85a9b 100644 --- a/src/gateway/server/ws-connection/auth-context.state.test.ts +++ b/src/gateway/server/ws-connection/auth-context.state.test.ts @@ -9,6 +9,7 @@ type TestRateLimiter = AuthRateLimiter & { check: ReturnType>; reset: ReturnType>; recordFailure: ReturnType>; + recordFailureAndDelay: ReturnType>; }; const CLIENT_IP = "203.0.113.20"; @@ -19,10 +20,16 @@ function createLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): T const check = vi.fn(() => ({ allowed, remaining: 10, retryAfterMs })); const reset = vi.fn(); const recordFailure = vi.fn(); + const recordFailureAndDelay = vi.fn( + async (ip, scope) => { + recordFailure(ip, scope); + }, + ); return { check, reset, recordFailure, + recordFailureAndDelay, size: vi.fn(() => 0), prune: vi.fn(), dispose: vi.fn(), diff --git a/src/gateway/server/ws-connection/auth-context.test.ts b/src/gateway/server/ws-connection/auth-context.test.ts index 0c8ed20f08c3..4e118126ce18 100644 --- a/src/gateway/server/ws-connection/auth-context.test.ts +++ b/src/gateway/server/ws-connection/auth-context.test.ts @@ -29,11 +29,17 @@ function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number } const check = vi.fn(() => ({ allowed, retryAfterMs })); const reset = vi.fn(); const recordFailure = vi.fn(); + const recordFailureAndDelay = vi.fn( + async (ip, scope) => { + recordFailure(ip, scope); + }, + ); return { limiter: { check, reset, recordFailure, + recordFailureAndDelay, } as unknown as AuthRateLimiter, check, reset, @@ -55,8 +61,13 @@ function createPerScopeRateLimiter( }); const reset = vi.fn(); const recordFailure = vi.fn(); + const recordFailureAndDelay = vi.fn( + async (ip, scope) => { + recordFailure(ip, scope); + }, + ); return { - limiter: { check, reset, recordFailure } as unknown as AuthRateLimiter, + limiter: { check, reset, recordFailure, recordFailureAndDelay } as unknown as AuthRateLimiter, check, reset, recordFailure, diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index 7b128c665d75..41a9fb2ca488 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -251,6 +251,7 @@ export async function authorizeGatewayConnectDevice( const allowSilentLocalPairing = allowSilentExistingNonOperatorPairing && shouldAllowSilentLocalPairing({ + autoApproveLocal: configSnapshot.gateway?.nodes?.pairing?.autoApproveLocal, locality: pairingLocality, hasBrowserOriginHeader, isControlUi, diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index bae931d5d17a..b92a5237e50f 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -43,6 +43,7 @@ function createRateLimiter(): AuthRateLimiter { check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), reset: () => {}, recordFailure: () => {}, + recordFailureAndDelay: async () => {}, size: () => 0, prune: () => {}, dispose: () => {}, @@ -235,6 +236,48 @@ describe("handshake auth helpers", () => { ).toBe(false); }); + it.each(["not-paired", "role-upgrade", "scope-upgrade"] as const)( + "requires explicit local approval for %s when autoApproveLocal is false", + (reason) => { + for (const locality of [ + "direct_local", + "cli_container_local", + "browser_container_local", + "shared_secret_loopback_local", + ] as const) { + expect( + allowSilentLocalPairing({ + autoApproveLocal: false, + locality, + hasBrowserOriginHeader: locality === "browser_container_local", + isControlUi: locality === "browser_container_local", + isWebchat: locality === "browser_container_local", + reason, + }), + ).toBe(false); + } + }, + ); + + it("keeps metadata refresh behavior unchanged when autoApproveLocal is false", () => { + expect( + allowSilentLocalPairing({ + autoApproveLocal: false, + locality: "shared_secret_loopback_local", + reason: "metadata-upgrade", + }), + ).toBe(true); + }); + + it.each([undefined, true])( + "preserves existing local approval behavior when autoApproveLocal is %s", + (autoApproveLocal) => { + for (const reason of ["not-paired", "role-upgrade", "scope-upgrade"] as const) { + expect(allowSilentLocalPairing({ autoApproveLocal, reason })).toBe(true); + } + }, + ); + it("allows Control UI or WebChat browser-origin pairing but keeps other browser-origin clients explicit", () => { expect( allowSilentLocalPairing({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 542a7e5e472d..e344d1756b62 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -89,6 +89,7 @@ export function resolveHandshakeBrowserSecurityContext(params: { } export function shouldAllowSilentLocalPairing(params: { + autoApproveLocal?: boolean; locality: PairingLocalityKind; hasBrowserOriginHeader: boolean; isControlUi: boolean; @@ -102,6 +103,12 @@ export function shouldAllowSilentLocalPairing(params: { if (params.hasBrowserOriginHeader && !params.isControlUi && !params.isWebchat) { return false; } + // Operators can require explicit approval for pairing and access upgrades. + // Metadata-only reconnect refreshes stay automatic to avoid approval churn + // after benign client or OS metadata changes. + if (params.autoApproveLocal === false && params.reason !== "metadata-upgrade") { + return false; + } if ( params.reason === "not-paired" || params.reason === "scope-upgrade" || diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 8fe30a0f347d..e9f904c88c91 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -849,6 +849,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { const rateLimiter: AuthRateLimiter = { check: vi.fn(() => ({ allowed: false, remaining: 0, retryAfterMs })), recordFailure: vi.fn(), + recordFailureAndDelay: vi.fn(async () => {}), reset: vi.fn(), size: vi.fn(() => 0), prune: vi.fn(),