Rate limit node pairing requests [AI] (#90147)

* fix: rate limit node pairing requests

* fix: preserve paired node reconnects
This commit is contained in:
Pavan Kumar Gondhi
2026-06-04 10:02:55 +05:30
committed by GitHub
parent 52f96fab51
commit 3f1e0ebb86
5 changed files with 268 additions and 10 deletions
+4
View File
@@ -39,6 +39,10 @@ export interface RateLimitConfig {
export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret";
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
// Per-IP gate for node-role pairing requests created during WebSocket connect.
// The request path enters the node-pairing storage lock, so bursts must be
// throttled before they queue behind that lock and delay operator actions.
export const AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING = "node-pairing";
// Per-IP gate for the pre-auth bootstrap-token verify path.
// `verifyDeviceBootstrapToken` is `withLock`-serialized in
// `device-bootstrap.ts` and runs fs read + fs write on every attempt;
@@ -174,6 +174,29 @@ describe("reconcileNodePairingOnConnect", () => {
expect(result.pendingPairing?.request.requestId).toBe("req-caps");
});
it("preserves the approved surface when paired node upgrade pairing is throttled", async () => {
const requestPairing = vi.fn(async () => null);
const result = await reconcileNodePairingOnConnect({
cfg: {} as never,
connectParams: makeNodeConnectParams({
caps: ["camera", "screen"],
commands: [],
}),
pairedNode: makePairedNode({
caps: ["camera"],
commands: [],
}),
requestPairing,
});
expect(requestPairing).toHaveBeenCalledOnce();
expect(result.effectiveCaps).toEqual(["camera"]);
expect(result.effectiveCommands).toEqual([]);
expect(result.declaredCaps).toEqual(["camera", "screen"]);
expect(result.pendingPairing).toBeUndefined();
});
it("requires a fresh pairing request when paired node permissions change", async () => {
const requestPairing = makePendingPairingRequest("req-permissions");
+5 -2
View File
@@ -112,7 +112,7 @@ export async function reconcileNodePairingOnConnect(params: {
connectParams: ConnectParams;
pairedNode: NodePairingPairedNode | null;
reportedClientIp?: string;
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult>;
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult | null>;
}): Promise<NodeConnectPairingReconcileResult> {
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
const policyNode = {
@@ -142,6 +142,9 @@ export async function reconcileNodePairingOnConnect(params: {
remoteIp: params.reportedClientIp,
}),
);
if (!pendingPairing) {
throw new Error("node pairing request required");
}
return {
nodeId,
declaredCaps,
@@ -204,7 +207,7 @@ export async function reconcileNodePairingOnConnect(params: {
effectiveCommands: effectiveApprovedDeclaredCommands,
declaredPermissions,
effectivePermissions: effectiveApprovedDeclaredPermissions,
pendingPairing,
...(pendingPairing ? { pendingPairing } : {}),
};
}
@@ -0,0 +1,163 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectReq,
installGatewayTestHooks,
testState,
trackConnectChallengeNonce,
withGatewayServer,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const NODE_CLIENT = {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "macos",
mode: GATEWAY_CLIENT_MODES.NODE,
deviceFamily: "Mac",
};
async function openWs(port: number) {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => {
ws.once("open", resolve);
});
return ws;
}
async function attemptNodePairing(port: number, identityPath: string) {
const ws = await openWs(port);
try {
return await connectReq(ws, {
token: "secret",
role: "node",
scopes: [],
client: NODE_CLIENT,
commands: ["system.run"],
deviceIdentityPath: identityPath,
});
} finally {
ws.close();
await new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
});
}
}
async function approveNodeIdentity(params: { identityPath: string; caps: string[] }) {
const identity = loadOrCreateDeviceIdentity(params.identityPath);
const request = await requestNodePairing({
nodeId: identity.deviceId,
platform: NODE_CLIENT.platform,
deviceFamily: NODE_CLIENT.deviceFamily,
caps: params.caps,
});
const approved = await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing"],
});
expect(approved && !("status" in approved)).toBe(true);
return identity;
}
describe("node pairing rate limit", () => {
test("limits concurrent first-time node pairing requests before the pairing lock", async () => {
testState.gatewayAuth = {
mode: "token",
token: "secret",
rateLimit: {
maxAttempts: 3,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
},
};
await withGatewayServer(async ({ port }) => {
const identityPrefix = path.join(os.tmpdir(), `openclaw-node-pairing-${randomUUID()}`);
const responses = await Promise.all(
Array.from(
{ length: 8 },
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.json`),
),
);
const rateLimited = responses.filter((res) => {
const details = res.error?.details as { code?: unknown; authReason?: unknown } | undefined;
return (
details?.code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED &&
details.authReason === "rate_limited"
);
});
const connected = responses.filter((res) => res.ok);
expect(connected).toHaveLength(3);
expect(rateLimited).toHaveLength(5);
expect((await listNodePairing()).pending).toHaveLength(3);
});
});
test("keeps paired reconnects on the approved surface when upgrade pairing is limited", async () => {
testState.gatewayAuth = {
mode: "token",
token: "secret",
rateLimit: {
maxAttempts: 3,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
},
};
await withGatewayServer(async ({ port }) => {
const identityPrefix = path.join(
os.tmpdir(),
`openclaw-node-pairing-upgrade-${randomUUID()}`,
);
const pairedIdentityPath = `${identityPrefix}-paired.json`;
await approveNodeIdentity({ identityPath: pairedIdentityPath, caps: ["camera"] });
const firstTimeResponses = await Promise.all(
Array.from(
{ length: 3 },
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.json`),
),
);
expect(firstTimeResponses.filter((res) => res.ok)).toHaveLength(3);
const ws = await openWs(port);
try {
const reconnect = await connectReq(ws, {
token: "secret",
role: "node",
scopes: [],
client: NODE_CLIENT,
caps: ["camera", "screen"],
deviceIdentityPath: pairedIdentityPath,
});
expect(reconnect.ok).toBe(true);
} finally {
ws.close();
await new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
});
}
expect((await listNodePairing()).pending).toHaveLength(3);
});
});
});
@@ -92,7 +92,7 @@ import {
isWebchatClient,
} from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, type AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
@@ -119,6 +119,7 @@ import {
resolvePluginNodeCapabilityExpiresAtMs,
setClientPluginNodeCapability,
} from "../../plugin-node-capability.js";
import { withSerializedRateLimitAttempt } from "../../rate-limit-attempt-serialization.js";
import { parseGatewayRole } from "../../role-policy.js";
import {
MAX_BUFFERED_BYTES,
@@ -172,6 +173,12 @@ const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
]);
const unauthorizedHandshakeLogLimiter = new HandshakeAuthLogLimiter();
class NodePairingRateLimitError extends Error {
constructor(readonly retryAfterMs: number) {
super("node pairing rate limited");
}
}
/** Match production release versions (YYYY.M.D or YYYY.M.D-beta.N). */
const RELEASED_VERSION_RE = /^\d{4}\.\d+\.\d+/;
@@ -198,6 +205,32 @@ function resolveLocalNodeId(): string | null {
return cachedLocalNodeId;
}
async function requestNodePairingFromConnect(params: {
input: Parameters<typeof requestNodePairing>[0];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
}): Promise<Awaited<ReturnType<typeof requestNodePairing>>> {
if (!params.rateLimiter) {
return await requestNodePairing(params.input);
}
return await withSerializedRateLimitAttempt({
ip: params.clientIp,
scope: AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING,
run: async () => {
const rateCheck = params.rateLimiter?.check(
params.clientIp,
AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING,
);
if (rateCheck && !rateCheck.allowed) {
throw new NodePairingRateLimitError(rateCheck.retryAfterMs);
}
const result = await requestNodePairing(params.input);
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING);
return result;
},
});
}
export type WsOriginCheckMetrics = {
hostHeaderFallbackAccepted: number;
};
@@ -1580,13 +1613,45 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
connectParams,
pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id),
reportedClientIp,
requestPairing: async (input) => await requestNodePairing(input),
});
let reconciliation: Awaited<ReturnType<typeof reconcileNodePairingOnConnect>>;
const pairedNode = await getPairedNode(
connectParams.device?.id ?? connectParams.client.id,
);
try {
reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
connectParams,
pairedNode,
reportedClientIp,
requestPairing: async (input) => {
try {
return await requestNodePairingFromConnect({
input,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
});
} catch (error) {
if (error instanceof NodePairingRateLimitError && pairedNode) {
// Paired upgrade reconnects can keep their approved surface;
// only the fresh pending request is throttled here.
return null;
}
throw error;
}
},
});
} catch (error) {
if (error instanceof NodePairingRateLimitError) {
rejectUnauthorized({
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: error.retryAfterMs,
});
return;
}
throw error;
}
if (reconciliation.pendingPairing?.created) {
const requestContext = buildRequestContext();
const resolvedAt = Date.now();