Skip to content

Commit 69637a5

Browse files
committed
fix(gateway): rate-limit pre-auth bootstrap-token verify to prevent mutex DoS
verifyDeviceBootstrapToken is withLock-serialized in src/infra/device-bootstrap.ts and runs an fs read + fs write per attempt. Reaching it in resolveConnectAuthDecision required only a connect frame with a valid Ed25519 device signature (trivially producible by an attacker with their own keypair) plus auth.bootstrapToken — the device-token sibling path was rate-limited but bootstrap-token was not, so an unauthenticated attacker could keep the bootstrap mutex saturated and starve legitimate node onboarding during the attack. Adds AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN with the same optimistic-check pattern as device-token: pre-check for lockout, run verify, recordFailure on mismatch, reset on success. The gate fires for browser-origin clients via the always-on browserRateLimiter (exemptLoopback: false) and for non-browser remote clients when gateway.auth.rateLimit is configured — same reachability envelope as the existing device-token bucket.
1 parent 07e0af4 commit 69637a5

5 files changed

Lines changed: 273 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2338,6 +2338,7 @@ Docs: https://docs.openclaw.ai
23382338
- Codex harness: honor `models.providers.openai-codex.models[].contextTokens` for native `openai/*` Codex runtime runs and `/status` context reporting, so subscription-backed Codex agents use the configured OAuth context cap without inflating past the runtime model window. Fixes #77858. Thanks @lilesjtu.
23392339
- Sessions cleanup: add `openclaw sessions cleanup --fix-dm-scope` so operators who return `session.dmScope` to `main` can dry-run and retire stale direct-DM session rows while preserving transcripts as deleted archives. Fixes #47561 and #45554. Thanks @BunsDev.
23402340
- Doctor/Codex: repair legacy `openai-codex/*` routes and cron payload model refs to canonical `openai/*`, keep OpenAI agent turns on Codex by default, ignore stale whole-agent/session runtime pins, preserve explicit provider/model runtime policy, and migrate legacy runtime model refs to model-scoped runtime entries. Thanks @vincentkoc.
2341+
- Gateway/security: rate-limit the pre-auth bootstrap-token verify per IP so a remote attacker producing a valid device signature cannot flood the mutex-serialized pairing-state read/write loop and stall legitimate node onboarding.
23412342
- Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback.
23422343
- Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations.
23432344
- Channels/message lifecycle: build legacy channel delivery results from message receipts and add receipts to BlueBubbles, Feishu, Google Chat, iMessage, IRC, LINE, Nextcloud Talk, QQ Bot, Signal, Synology Chat, Tlon, Twitch, WhatsApp, Zalo, and Zalo Personal send results and owner-path reply delivery plus Discord, Matrix, Mattermost, Slack, and Teams send results while preserving existing message id compatibility.

src/gateway/auth-rate-limit.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export interface RateLimitConfig {
3838
export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
3939
export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret";
4040
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
41+
// Per-IP gate for the pre-auth bootstrap-token verify path.
42+
// `verifyDeviceBootstrapToken` is `withLock`-serialized in
43+
// `device-bootstrap.ts` and runs fs read + fs write on every attempt;
44+
// without a scope-specific limiter, attackers presenting a valid
45+
// device signature can queue the bootstrap-pairing flow behind their
46+
// requests, blocking legitimate node onboarding during the attack.
47+
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
4148
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
4249
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
4350

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { randomUUID } from "node:crypto";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { describe, expect, test } from "vitest";
5+
import { WebSocket } from "ws";
6+
import {
7+
connectReq,
8+
installGatewayTestHooks,
9+
testState,
10+
trackConnectChallengeNonce,
11+
withGatewayServer,
12+
} from "./test-helpers.js";
13+
14+
installGatewayTestHooks({ scope: "suite" });
15+
16+
async function openWs(port: number) {
17+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
18+
trackConnectChallengeNonce(ws);
19+
await new Promise<void>((resolve) => ws.once("open", resolve));
20+
return ws;
21+
}
22+
23+
async function attemptForgedBootstrap(port: number, identityPath: string) {
24+
const ws = await openWs(port);
25+
try {
26+
const res = await connectReq(ws, {
27+
skipDefaultAuth: true,
28+
bootstrapToken: "forged-bootstrap-token",
29+
deviceIdentityPath: identityPath,
30+
});
31+
return res;
32+
} finally {
33+
ws.close();
34+
await new Promise<void>((resolve) => {
35+
if (ws.readyState === WebSocket.CLOSED) {
36+
resolve();
37+
return;
38+
}
39+
ws.once("close", () => resolve());
40+
});
41+
}
42+
}
43+
44+
describe("pre-auth bootstrap-token rate limit", () => {
45+
test("locks out forged bootstrap-token attempts after maxAttempts", async () => {
46+
// exemptLoopback:false ensures the limiter applies to loopback test
47+
// clients. In production the same gate applies to remote clients via
48+
// the per-IP bucket.
49+
testState.gatewayAuth = {
50+
mode: "token",
51+
token: "secret",
52+
rateLimit: {
53+
maxAttempts: 3,
54+
windowMs: 60_000,
55+
lockoutMs: 60_000,
56+
exemptLoopback: false,
57+
},
58+
};
59+
await withGatewayServer(async ({ port }) => {
60+
const identityPath = path.join(
61+
os.tmpdir(),
62+
`openclaw-preauth-bootstrap-${randomUUID()}.json`,
63+
);
64+
65+
// The first maxAttempts forged tokens reach verifyDeviceBootstrapToken
66+
// and fail with bootstrap_token_invalid (the verify path ran).
67+
const reasons: Array<string | undefined> = [];
68+
for (let i = 0; i < 3; i++) {
69+
const res = await attemptForgedBootstrap(port, identityPath);
70+
expect(res.ok).toBe(false);
71+
const detail = res.error?.details as { authReason?: string } | undefined;
72+
reasons.push(detail?.authReason);
73+
}
74+
expect(reasons.every((r) => r === "bootstrap_token_invalid")).toBe(true);
75+
76+
// The next attempt is the one that proves the gate fires: the gateway
77+
// rejects without invoking the mutex-locked verify path.
78+
const lockedOut = await attemptForgedBootstrap(port, identityPath);
79+
expect(lockedOut.ok).toBe(false);
80+
const detail = lockedOut.error?.details as
81+
| { authReason?: string; retryAfterMs?: number }
82+
| undefined;
83+
expect(detail?.authReason).toBe("rate_limited");
84+
});
85+
});
86+
87+
test("forged bootstrap-token failures consume their own bucket independent of device-token", async () => {
88+
testState.gatewayAuth = {
89+
mode: "token",
90+
token: "secret",
91+
rateLimit: {
92+
maxAttempts: 1,
93+
windowMs: 60_000,
94+
lockoutMs: 60_000,
95+
exemptLoopback: false,
96+
},
97+
};
98+
await withGatewayServer(async ({ port }) => {
99+
const identityPath = path.join(
100+
os.tmpdir(),
101+
`openclaw-preauth-bootstrap-shared-${randomUUID()}.json`,
102+
);
103+
104+
const first = await attemptForgedBootstrap(port, identityPath);
105+
expect(first.ok).toBe(false);
106+
const firstDetail = first.error?.details as { authReason?: string } | undefined;
107+
expect(firstDetail?.authReason).toBe("bootstrap_token_invalid");
108+
109+
const second = await attemptForgedBootstrap(port, identityPath);
110+
expect(second.ok).toBe(false);
111+
const secondDetail = second.error?.details as { authReason?: string } | undefined;
112+
expect(secondDetail?.authReason).toBe("rate_limited");
113+
});
114+
});
115+
});

src/gateway/server/ws-connection/auth-context.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ type VerifyBootstrapTokenFn = Parameters<
99

1010
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
1111
limiter: AuthRateLimiter;
12+
check: ReturnType<typeof vi.fn>;
1213
reset: ReturnType<typeof vi.fn>;
14+
recordFailure: ReturnType<typeof vi.fn>;
1315
} {
1416
const allowed = params?.allowed ?? true;
1517
const retryAfterMs = params?.retryAfterMs ?? 5_000;
@@ -22,7 +24,31 @@ function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }
2224
reset,
2325
recordFailure,
2426
} as unknown as AuthRateLimiter,
27+
check,
2528
reset,
29+
recordFailure,
30+
};
31+
}
32+
33+
function createPerScopeRateLimiter(
34+
scopes: Record<string, { allowed: boolean; retryAfterMs?: number }>,
35+
): {
36+
limiter: AuthRateLimiter;
37+
check: ReturnType<typeof vi.fn>;
38+
reset: ReturnType<typeof vi.fn>;
39+
recordFailure: ReturnType<typeof vi.fn>;
40+
} {
41+
const check = vi.fn((_ip: string | undefined, scope?: string) => {
42+
const cfg = scopes[scope ?? ""] ?? { allowed: true };
43+
return { allowed: cfg.allowed, retryAfterMs: cfg.retryAfterMs ?? 5_000 };
44+
});
45+
const reset = vi.fn();
46+
const recordFailure = vi.fn();
47+
return {
48+
limiter: { check, reset, recordFailure } as unknown as AuthRateLimiter,
49+
check,
50+
reset,
51+
recordFailure,
2652
};
2753
}
2854

@@ -281,4 +307,82 @@ describe("resolveConnectAuthDecision", () => {
281307
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
282308
expect(verifyDeviceToken).not.toHaveBeenCalled();
283309
});
310+
311+
it("gates bootstrap-token verify when the bootstrap-token bucket is exceeded", async () => {
312+
const rateLimiter = createPerScopeRateLimiter({
313+
"bootstrap-token": { allowed: false, retryAfterMs: 30_000 },
314+
"device-token": { allowed: true },
315+
"shared-secret": { allowed: true },
316+
});
317+
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
318+
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
319+
const decision = await resolveDeviceTokenDecision({
320+
verifyBootstrapToken,
321+
verifyDeviceToken,
322+
rateLimiter: rateLimiter.limiter,
323+
clientIp: "203.0.113.20",
324+
stateOverrides: {
325+
bootstrapTokenCandidate: "bootstrap-token",
326+
deviceTokenCandidate: undefined,
327+
deviceTokenCandidateSource: undefined,
328+
},
329+
});
330+
expect(decision.authOk).toBe(false);
331+
expect(decision.authResult.reason).toBe("rate_limited");
332+
expect(decision.authResult.retryAfterMs).toBe(30_000);
333+
// The verify path is mutex-locked + does fs I/O — confirm we never invoke
334+
// it once the bucket is exhausted.
335+
expect(verifyBootstrapToken).not.toHaveBeenCalled();
336+
});
337+
338+
it("records a bootstrap-token failure when the verify rejects", async () => {
339+
const rateLimiter = createPerScopeRateLimiter({
340+
"bootstrap-token": { allowed: true },
341+
"device-token": { allowed: true },
342+
"shared-secret": { allowed: true },
343+
});
344+
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
345+
ok: false,
346+
reason: "bootstrap_token_invalid",
347+
}));
348+
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
349+
await resolveDeviceTokenDecision({
350+
verifyBootstrapToken,
351+
verifyDeviceToken,
352+
rateLimiter: rateLimiter.limiter,
353+
clientIp: "203.0.113.20",
354+
stateOverrides: {
355+
bootstrapTokenCandidate: "bootstrap-token",
356+
deviceTokenCandidate: undefined,
357+
deviceTokenCandidateSource: undefined,
358+
},
359+
});
360+
expect(rateLimiter.recordFailure).toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
361+
expect(rateLimiter.reset).not.toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
362+
});
363+
364+
it("resets the bootstrap-token bucket when the verify succeeds", async () => {
365+
const rateLimiter = createPerScopeRateLimiter({
366+
"bootstrap-token": { allowed: true },
367+
"device-token": { allowed: true },
368+
"shared-secret": { allowed: true },
369+
});
370+
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
371+
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
372+
const decision = await resolveDeviceTokenDecision({
373+
verifyBootstrapToken,
374+
verifyDeviceToken,
375+
rateLimiter: rateLimiter.limiter,
376+
clientIp: "203.0.113.20",
377+
stateOverrides: {
378+
bootstrapTokenCandidate: "bootstrap-token",
379+
deviceTokenCandidate: undefined,
380+
deviceTokenCandidateSource: undefined,
381+
},
382+
});
383+
expect(decision.authOk).toBe(true);
384+
expect(decision.authMethod).toBe("bootstrap-token");
385+
expect(rateLimiter.reset).toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
386+
expect(rateLimiter.recordFailure).not.toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
387+
});
284388
});

src/gateway/server/ws-connection/auth-context.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { IncomingMessage } from "node:http";
22
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
33
import {
4+
AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
45
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
56
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
67
type AuthRateLimiter,
@@ -187,23 +188,51 @@ export async function resolveConnectAuthDecision(params: {
187188

188189
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
189190
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
190-
const tokenCheck = await params.verifyBootstrapToken({
191-
deviceId: params.deviceId,
192-
publicKey: params.publicKey,
193-
token: bootstrapTokenCandidate,
194-
role: params.role,
195-
scopes: params.scopes,
196-
});
197-
if (tokenCheck.ok) {
198-
// Prefer an explicit valid bootstrap token even when another auth path
199-
// (for example tailscale serve header auth) already succeeded. QR pairing
200-
// relies on the server classifying the handshake as bootstrap-token so the
201-
// initial node pairing can be silently auto-approved and the bootstrap
202-
// token can be revoked after approval.
203-
authOk = true;
204-
authMethod = "bootstrap-token";
205-
} else if (!authOk) {
206-
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
191+
// Per-IP gate on the bootstrap-token verify path.
192+
// verifyDeviceBootstrapToken is mutex-serialized and runs fs read + fs
193+
// write per attempt, so unrate-limited attackers can queue the bootstrap
194+
// pairing flow behind their requests and block legitimate onboarding.
195+
let bootstrapRateLimited = false;
196+
if (params.rateLimiter) {
197+
const bootstrapRateCheck = params.rateLimiter.check(
198+
params.clientIp,
199+
AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
200+
);
201+
if (!bootstrapRateCheck.allowed) {
202+
bootstrapRateLimited = true;
203+
if (!authOk) {
204+
authResult = {
205+
ok: false,
206+
reason: "rate_limited",
207+
rateLimited: true,
208+
retryAfterMs: bootstrapRateCheck.retryAfterMs,
209+
};
210+
}
211+
}
212+
}
213+
if (!bootstrapRateLimited) {
214+
const tokenCheck = await params.verifyBootstrapToken({
215+
deviceId: params.deviceId,
216+
publicKey: params.publicKey,
217+
token: bootstrapTokenCandidate,
218+
role: params.role,
219+
scopes: params.scopes,
220+
});
221+
if (tokenCheck.ok) {
222+
// Prefer an explicit valid bootstrap token even when another auth path
223+
// (for example tailscale serve header auth) already succeeded. QR pairing
224+
// relies on the server classifying the handshake as bootstrap-token so the
225+
// initial node pairing can be silently auto-approved and the bootstrap
226+
// token can be revoked after approval.
227+
authOk = true;
228+
authMethod = "bootstrap-token";
229+
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
230+
} else {
231+
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
232+
if (!authOk) {
233+
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
234+
}
235+
}
207236
}
208237
}
209238

0 commit comments

Comments
 (0)