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
This commit is contained in:
Peter Steinberger
2026-07-29 12:25:57 -04:00
committed by GitHub
parent a877fe1b32
commit ea967be0df
26 changed files with 438 additions and 30 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
{
"core": 2305,
"core": 2306,
"channel": 3648,
"plugin": 3583
"plugin": 3601
}
+4 -4
View File
@@ -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
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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.
+31
View File
@@ -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
+1
View File
@@ -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.
@@ -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",
@@ -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"] },
+2
View File
@@ -231,6 +231,8 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"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":
+1
View File
@@ -365,6 +365,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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,
+1
View File
@@ -56,6 +56,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"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"],
+1 -1
View File
@@ -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
+6
View File
@@ -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.
+1
View File
@@ -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([
+150 -1
View File
@@ -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();
}
});
});
+109 -8
View File
@@ -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<void>;
/** 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<string, RateLimitEntry>();
// 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<string, number>();
const loopbackPenaltyWaiters = new Map<
string,
{ deadline: number; resolvers: (() => void)[]; timer: ReturnType<typeof setTimeout> }
>();
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<void> {
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<void>((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<typeof setTimeout> {
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 };
}
+6
View File
@@ -23,10 +23,16 @@ function createLimiterSpy(): AuthRateLimiter & {
(_ip, _scope) => ({ allowed: true, remaining: 10, retryAfterMs: 0 }) as const,
);
const recordFailure = vi.fn<AuthRateLimiter["recordFailure"]>((_ip, _scope) => {});
const recordFailureAndDelay = vi.fn<AuthRateLimiter["recordFailureAndDelay"]>(
async (ip, scope) => {
recordFailure(ip, scope);
},
);
const reset = vi.fn<AuthRateLimiter["reset"]>((_ip, _scope) => {});
return {
check,
recordFailure,
recordFailureAndDelay,
reset,
size: () => 0,
prune: () => {},
+10 -10
View File
@@ -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<GatewayAuthResult> {
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<GatewayAuthResult> {
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" };
}
+1 -1
View File
@@ -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);
}
}
}
@@ -9,6 +9,7 @@ type TestRateLimiter = AuthRateLimiter & {
check: ReturnType<typeof vi.fn<AuthRateLimiter["check"]>>;
reset: ReturnType<typeof vi.fn<AuthRateLimiter["reset"]>>;
recordFailure: ReturnType<typeof vi.fn<AuthRateLimiter["recordFailure"]>>;
recordFailureAndDelay: ReturnType<typeof vi.fn<AuthRateLimiter["recordFailureAndDelay"]>>;
};
const CLIENT_IP = "203.0.113.20";
@@ -19,10 +20,16 @@ function createLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): T
const check = vi.fn<AuthRateLimiter["check"]>(() => ({ allowed, remaining: 10, retryAfterMs }));
const reset = vi.fn<AuthRateLimiter["reset"]>();
const recordFailure = vi.fn<AuthRateLimiter["recordFailure"]>();
const recordFailureAndDelay = vi.fn<AuthRateLimiter["recordFailureAndDelay"]>(
async (ip, scope) => {
recordFailure(ip, scope);
},
);
return {
check,
reset,
recordFailure,
recordFailureAndDelay,
size: vi.fn(() => 0),
prune: vi.fn(),
dispose: vi.fn(),
@@ -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<AuthRateLimiter["recordFailureAndDelay"]>(
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<AuthRateLimiter["recordFailureAndDelay"]>(
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,
@@ -251,6 +251,7 @@ export async function authorizeGatewayConnectDevice(
const allowSilentLocalPairing =
allowSilentExistingNonOperatorPairing &&
shouldAllowSilentLocalPairing({
autoApproveLocal: configSnapshot.gateway?.nodes?.pairing?.autoApproveLocal,
locality: pairingLocality,
hasBrowserOriginHeader,
isControlUi,
@@ -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({
@@ -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" ||
@@ -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(),