mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 15:52:52 +00:00
fix(gateway): rate-limit bootstrap-token verification
Gateway/security: rate-limits pre-auth bootstrap-token verification and serializes per-IP attempts to prevent mutex-stall DoS while preserving device-token fallback. Fixes #77978. Co-authored-by: Federico Kamelhar <federico.kamelhar@oracle.com>
This commit is contained in:
committed by
GitHub
parent
ef04c72f08
commit
ecbd97e968
@@ -39,6 +39,13 @@ 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 the pre-auth bootstrap-token verify path.
|
||||
// `verifyDeviceBootstrapToken` is `withLock`-serialized in
|
||||
// `device-bootstrap.ts` and runs fs read + fs write on every attempt;
|
||||
// without a scope-specific limiter, attackers presenting a valid
|
||||
// device signature can queue the bootstrap-pairing flow behind their
|
||||
// requests, blocking legitimate node onboarding during the attack.
|
||||
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
|
||||
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
|
||||
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
|
||||
|
||||
|
||||
106
src/gateway/server.preauth-bootstrap-token-rate-limit.test.ts
Normal file
106
src/gateway/server.preauth-bootstrap-token-rate-limit.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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 {
|
||||
connectReq,
|
||||
installGatewayTestHooks,
|
||||
testState,
|
||||
trackConnectChallengeNonce,
|
||||
withGatewayServer,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
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 attemptForgedBootstrap(port: number, identityPath: string) {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: "forged-bootstrap-token",
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
return res;
|
||||
} finally {
|
||||
ws.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ws.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("pre-auth bootstrap-token rate limit", () => {
|
||||
test("locks out concurrent forged bootstrap-token attempts after maxAttempts", async () => {
|
||||
// exemptLoopback:false ensures the limiter applies to loopback test
|
||||
// clients. In production the same gate applies to remote clients via
|
||||
// the per-IP bucket.
|
||||
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-preauth-bootstrap-${randomUUID()}`);
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
async (_, index) => await attemptForgedBootstrap(port, `${identityPrefix}-${index}.json`),
|
||||
),
|
||||
);
|
||||
const reasons = responses.map((res) => {
|
||||
expect(res.ok).toBe(false);
|
||||
const detail = res.error?.details as { authReason?: string } | undefined;
|
||||
return detail?.authReason;
|
||||
});
|
||||
expect(reasons.filter((reason) => reason === "bootstrap_token_invalid")).toHaveLength(3);
|
||||
expect(reasons.filter((reason) => reason === "rate_limited")).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
test("forged bootstrap-token failures consume their own bucket independent of device-token", async () => {
|
||||
testState.gatewayAuth = {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
rateLimit: {
|
||||
maxAttempts: 1,
|
||||
windowMs: 60_000,
|
||||
lockoutMs: 60_000,
|
||||
exemptLoopback: false,
|
||||
},
|
||||
};
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const identityPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-preauth-bootstrap-shared-${randomUUID()}.json`,
|
||||
);
|
||||
|
||||
const first = await attemptForgedBootstrap(port, identityPath);
|
||||
expect(first.ok).toBe(false);
|
||||
const firstDetail = first.error?.details as { authReason?: string } | undefined;
|
||||
expect(firstDetail?.authReason).toBe("bootstrap_token_invalid");
|
||||
|
||||
const second = await attemptForgedBootstrap(port, identityPath);
|
||||
expect(second.ok).toBe(false);
|
||||
const secondDetail = second.error?.details as { authReason?: string } | undefined;
|
||||
expect(secondDetail?.authReason).toBe("rate_limited");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import { createAuthRateLimiter, type AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
|
||||
|
||||
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
|
||||
@@ -9,7 +9,9 @@ type VerifyBootstrapTokenFn = Parameters<
|
||||
|
||||
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
|
||||
limiter: AuthRateLimiter;
|
||||
check: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
recordFailure: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const allowed = params?.allowed ?? true;
|
||||
const retryAfterMs = params?.retryAfterMs ?? 5_000;
|
||||
@@ -22,7 +24,31 @@ function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }
|
||||
reset,
|
||||
recordFailure,
|
||||
} as unknown as AuthRateLimiter,
|
||||
check,
|
||||
reset,
|
||||
recordFailure,
|
||||
};
|
||||
}
|
||||
|
||||
function createPerScopeRateLimiter(
|
||||
scopes: Record<string, { allowed: boolean; retryAfterMs?: number }>,
|
||||
): {
|
||||
limiter: AuthRateLimiter;
|
||||
check: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
recordFailure: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const check = vi.fn((_ip: string | undefined, scope?: string) => {
|
||||
const cfg = scopes[scope ?? ""] ?? { allowed: true };
|
||||
return { allowed: cfg.allowed, retryAfterMs: cfg.retryAfterMs ?? 5_000 };
|
||||
});
|
||||
const reset = vi.fn();
|
||||
const recordFailure = vi.fn();
|
||||
return {
|
||||
limiter: { check, reset, recordFailure } as unknown as AuthRateLimiter,
|
||||
check,
|
||||
reset,
|
||||
recordFailure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,4 +307,179 @@ describe("resolveConnectAuthDecision", () => {
|
||||
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
|
||||
expect(verifyDeviceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gates bootstrap-token verify when the bootstrap-token bucket is exceeded", async () => {
|
||||
const rateLimiter = createPerScopeRateLimiter({
|
||||
"bootstrap-token": { allowed: false, retryAfterMs: 30_000 },
|
||||
"device-token": { allowed: true },
|
||||
"shared-secret": { allowed: true },
|
||||
});
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: undefined,
|
||||
deviceTokenCandidateSource: undefined,
|
||||
},
|
||||
});
|
||||
expect(decision.authOk).toBe(false);
|
||||
expect(decision.authResult.reason).toBe("rate_limited");
|
||||
expect(decision.authResult.retryAfterMs).toBe(30_000);
|
||||
// The verify path is mutex-locked + does fs I/O — confirm we never invoke
|
||||
// it once the bucket is exhausted.
|
||||
expect(verifyBootstrapToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still verifies the device token when only the bootstrap-token path is rate-limited", async () => {
|
||||
const rateLimiter = createPerScopeRateLimiter({
|
||||
"bootstrap-token": { allowed: false, retryAfterMs: 30_000 },
|
||||
"device-token": { allowed: true },
|
||||
"shared-secret": { allowed: true },
|
||||
});
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: "device-token",
|
||||
},
|
||||
});
|
||||
expect(decision.authOk).toBe(true);
|
||||
expect(decision.authMethod).toBe("device-token");
|
||||
expect(verifyBootstrapToken).not.toHaveBeenCalled();
|
||||
expect(verifyDeviceToken).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("records a bootstrap-token failure when final auth rejects", async () => {
|
||||
const rateLimiter = createPerScopeRateLimiter({
|
||||
"bootstrap-token": { allowed: true },
|
||||
"device-token": { allowed: true },
|
||||
"shared-secret": { allowed: true },
|
||||
});
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
|
||||
ok: false,
|
||||
reason: "bootstrap_token_invalid",
|
||||
}));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: undefined,
|
||||
deviceTokenCandidateSource: undefined,
|
||||
},
|
||||
});
|
||||
expect(rateLimiter.recordFailure).toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
|
||||
expect(rateLimiter.reset).not.toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
|
||||
});
|
||||
|
||||
it("does not record a bootstrap-token failure when device-token fallback succeeds", async () => {
|
||||
const rateLimiter = createPerScopeRateLimiter({
|
||||
"bootstrap-token": { allowed: true },
|
||||
"device-token": { allowed: true },
|
||||
"shared-secret": { allowed: true },
|
||||
});
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
|
||||
ok: false,
|
||||
reason: "bootstrap_token_invalid",
|
||||
}));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: "device-token",
|
||||
},
|
||||
});
|
||||
expect(decision.authOk).toBe(true);
|
||||
expect(decision.authMethod).toBe("device-token");
|
||||
expect(rateLimiter.recordFailure).not.toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
|
||||
});
|
||||
|
||||
it("serializes concurrent bootstrap-token failures before checking the next attempt", async () => {
|
||||
const rateLimiter = createAuthRateLimiter({
|
||||
maxAttempts: 3,
|
||||
windowMs: 60_000,
|
||||
lockoutMs: 60_000,
|
||||
exemptLoopback: false,
|
||||
pruneIntervalMs: 0,
|
||||
});
|
||||
let activeBootstrapChecks = 0;
|
||||
let maxActiveBootstrapChecks = 0;
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => {
|
||||
activeBootstrapChecks += 1;
|
||||
maxActiveBootstrapChecks = Math.max(maxActiveBootstrapChecks, activeBootstrapChecks);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
activeBootstrapChecks -= 1;
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
});
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
try {
|
||||
const decisions = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
async () =>
|
||||
await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: undefined,
|
||||
deviceTokenCandidateSource: undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const reasons = decisions.map((decision) => decision.authResult.reason);
|
||||
expect(reasons.filter((reason) => reason === "bootstrap_token_invalid")).toHaveLength(3);
|
||||
expect(reasons.filter((reason) => reason === "rate_limited")).toHaveLength(5);
|
||||
expect(verifyBootstrapToken).toHaveBeenCalledTimes(3);
|
||||
expect(maxActiveBootstrapChecks).toBe(1);
|
||||
expect(verifyDeviceToken).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rateLimiter.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets the bootstrap-token bucket when the verify succeeds", async () => {
|
||||
const rateLimiter = createPerScopeRateLimiter({
|
||||
"bootstrap-token": { allowed: true },
|
||||
"device-token": { allowed: true },
|
||||
"shared-secret": { allowed: true },
|
||||
});
|
||||
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
|
||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||
const decision = await resolveDeviceTokenDecision({
|
||||
verifyBootstrapToken,
|
||||
verifyDeviceToken,
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
stateOverrides: {
|
||||
bootstrapTokenCandidate: "bootstrap-token",
|
||||
deviceTokenCandidate: undefined,
|
||||
deviceTokenCandidateSource: undefined,
|
||||
},
|
||||
});
|
||||
expect(decision.authOk).toBe(true);
|
||||
expect(decision.authMethod).toBe("bootstrap-token");
|
||||
expect(rateLimiter.reset).toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
|
||||
expect(rateLimiter.recordFailure).not.toHaveBeenCalledWith("203.0.113.20", "bootstrap-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
|
||||
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
||||
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
type AuthRateLimiter,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type GatewayAuthResult,
|
||||
type ResolvedGatewayAuth,
|
||||
} from "../../auth.js";
|
||||
import { withSerializedRateLimitAttempt } from "../../rate-limit-attempt-serialization.js";
|
||||
|
||||
type HandshakeConnectAuth = {
|
||||
token?: string;
|
||||
@@ -52,6 +54,30 @@ export type ConnectAuthDecision = {
|
||||
deviceTokenSharedGatewaySessionGeneration?: string;
|
||||
};
|
||||
|
||||
type ResolveConnectAuthDecisionParams = {
|
||||
state: ConnectAuthState;
|
||||
hasDeviceIdentity: boolean;
|
||||
deviceId?: string;
|
||||
publicKey?: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
clientIp?: string;
|
||||
verifyBootstrapToken: (params: {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
}) => Promise<VerifyBootstrapTokenResult>;
|
||||
verifyDeviceToken: (params: {
|
||||
deviceId: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
}) => Promise<VerifyDeviceTokenResult>;
|
||||
};
|
||||
|
||||
function mapDeviceTokenAuthFailureReason(params: {
|
||||
tokenCheckReason?: string;
|
||||
candidateSource?: DeviceTokenCandidateSource;
|
||||
@@ -157,59 +183,100 @@ export async function resolveConnectAuthState(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveConnectAuthDecision(params: {
|
||||
state: ConnectAuthState;
|
||||
hasDeviceIdentity: boolean;
|
||||
deviceId?: string;
|
||||
publicKey?: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
clientIp?: string;
|
||||
verifyBootstrapToken: (params: {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
}) => Promise<VerifyBootstrapTokenResult>;
|
||||
verifyDeviceToken: (params: {
|
||||
deviceId: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
}) => Promise<VerifyDeviceTokenResult>;
|
||||
}): Promise<ConnectAuthDecision> {
|
||||
export async function resolveConnectAuthDecision(
|
||||
params: ResolveConnectAuthDecisionParams,
|
||||
): Promise<ConnectAuthDecision> {
|
||||
const shouldSerializeBootstrapAttempt = Boolean(
|
||||
params.rateLimiter &&
|
||||
params.hasDeviceIdentity &&
|
||||
params.deviceId &&
|
||||
params.publicKey &&
|
||||
params.state.bootstrapTokenCandidate,
|
||||
);
|
||||
if (!shouldSerializeBootstrapAttempt) {
|
||||
return await resolveConnectAuthDecisionCore(params);
|
||||
}
|
||||
return await withSerializedRateLimitAttempt({
|
||||
ip: params.clientIp,
|
||||
scope: AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
|
||||
run: async () => await resolveConnectAuthDecisionCore(params),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveConnectAuthDecisionCore(
|
||||
params: ResolveConnectAuthDecisionParams,
|
||||
): Promise<ConnectAuthDecision> {
|
||||
let authResult = params.state.authResult;
|
||||
let authOk = params.state.authOk;
|
||||
let authMethod = params.state.authMethod;
|
||||
let deviceTokenSharedGatewaySessionGeneration: string | undefined;
|
||||
let pendingBootstrapFailure = false;
|
||||
|
||||
function finish(): ConnectAuthDecision {
|
||||
if (pendingBootstrapFailure && !authOk) {
|
||||
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
|
||||
}
|
||||
return {
|
||||
authResult,
|
||||
authOk,
|
||||
authMethod,
|
||||
deviceTokenSharedGatewaySessionGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
|
||||
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
|
||||
const tokenCheck = await params.verifyBootstrapToken({
|
||||
deviceId: params.deviceId,
|
||||
publicKey: params.publicKey,
|
||||
token: bootstrapTokenCandidate,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
});
|
||||
if (tokenCheck.ok) {
|
||||
// Prefer an explicit valid bootstrap token even when another auth path
|
||||
// (for example tailscale serve header auth) already succeeded. QR pairing
|
||||
// relies on the server classifying the handshake as bootstrap-token so the
|
||||
// initial node pairing can be silently auto-approved and the bootstrap
|
||||
// token can be revoked after approval.
|
||||
authOk = true;
|
||||
authMethod = "bootstrap-token";
|
||||
} else if (!authOk) {
|
||||
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
|
||||
// Per-IP gate on the bootstrap-token verify path.
|
||||
// verifyDeviceBootstrapToken is mutex-serialized and runs fs read + fs
|
||||
// write per attempt, so unrate-limited attackers can queue the bootstrap
|
||||
// pairing flow behind their requests and block legitimate onboarding.
|
||||
let bootstrapRateLimited = false;
|
||||
if (params.rateLimiter) {
|
||||
const bootstrapRateCheck = params.rateLimiter.check(
|
||||
params.clientIp,
|
||||
AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
|
||||
);
|
||||
if (!bootstrapRateCheck.allowed) {
|
||||
bootstrapRateLimited = true;
|
||||
if (!authOk) {
|
||||
authResult = {
|
||||
ok: false,
|
||||
reason: "rate_limited",
|
||||
rateLimited: true,
|
||||
retryAfterMs: bootstrapRateCheck.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bootstrapRateLimited) {
|
||||
const tokenCheck = await params.verifyBootstrapToken({
|
||||
deviceId: params.deviceId,
|
||||
publicKey: params.publicKey,
|
||||
token: bootstrapTokenCandidate,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
});
|
||||
if (tokenCheck.ok) {
|
||||
// Prefer an explicit valid bootstrap token even when another auth path
|
||||
// (for example tailscale serve header auth) already succeeded. QR pairing
|
||||
// relies on the server classifying the handshake as bootstrap-token so the
|
||||
// initial node pairing can be silently auto-approved and the bootstrap
|
||||
// token can be revoked after approval.
|
||||
authOk = true;
|
||||
authMethod = "bootstrap-token";
|
||||
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
|
||||
} else {
|
||||
pendingBootstrapFailure = true;
|
||||
if (!authOk) {
|
||||
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deviceTokenCandidate = params.state.deviceTokenCandidate;
|
||||
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
|
||||
return { authResult, authOk, authMethod };
|
||||
return finish();
|
||||
}
|
||||
|
||||
let deviceTokenRateLimited = false;
|
||||
@@ -258,10 +325,5 @@ export async function resolveConnectAuthDecision(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authResult,
|
||||
authOk,
|
||||
authMethod,
|
||||
deviceTokenSharedGatewaySessionGeneration,
|
||||
};
|
||||
return finish();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user