mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
a877fe1b32
commit
ea967be0df
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2305,
|
||||
"core": 2306,
|
||||
"channel": 3648,
|
||||
"plugin": 3583
|
||||
"plugin": 3601
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user