mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
Rate limit node pairing requests [AI] (#90147)
* fix: rate limit node pairing requests * fix: preserve paired node reconnects
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user