Skip to content

Commit 5fe5e8c

Browse files
Rate-limit bootstrap and device signature auth
1 parent 7d5ca30 commit 5fe5e8c

5 files changed

Lines changed: 158 additions & 18 deletions

File tree

src/gateway/auth-rate-limit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export interface RateLimitConfig {
3737

3838
export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
3939
export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret";
40+
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
4041
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
42+
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE = "device-signature";
4143
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
4244
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
4345

src/gateway/server.auth.default-token.suite.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
sendRawConnectReq,
2020
startGatewayServer,
2121
TEST_OPERATOR_CLIENT,
22+
testState,
2223
waitForWsClose,
2324
withGatewayServer,
2425
withRuntimeVersionEnv,
@@ -398,6 +399,53 @@ export function registerDefaultAuthTokenSuite(): void {
398399
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
399400
});
400401

402+
test("rate-limits repeated invalid device signatures", async () => {
403+
testState.gatewayAuth = {
404+
mode: "token",
405+
token: "secret",
406+
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false },
407+
};
408+
await withGatewayServer(async ({ port: isolatedPort }) => {
409+
const firstWs = await openWs(isolatedPort);
410+
const firstNonce = await readConnectChallengeNonce(firstWs);
411+
const { device: firstDevice } = await createSignedDevice({
412+
token: "secret",
413+
scopes: ["operator.admin"],
414+
clientId: GATEWAY_CLIENT_NAMES.TEST,
415+
clientMode: GATEWAY_CLIENT_MODES.TEST,
416+
nonce: firstNonce,
417+
});
418+
const first = await sendRawConnectReq(firstWs, {
419+
id: "c-invalid-device-signature-1",
420+
token: "secret",
421+
device: firstDevice,
422+
});
423+
expect(first.ok).toBe(false);
424+
expect(first.error?.details?.code).toBe(
425+
ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID,
426+
);
427+
firstWs.close();
428+
429+
const secondWs = await openWs(isolatedPort);
430+
const secondNonce = await readConnectChallengeNonce(secondWs);
431+
const { device: secondDevice } = await createSignedDevice({
432+
token: "secret",
433+
scopes: ["operator.admin"],
434+
clientId: GATEWAY_CLIENT_NAMES.TEST,
435+
clientMode: GATEWAY_CLIENT_MODES.TEST,
436+
nonce: secondNonce,
437+
});
438+
const second = await sendRawConnectReq(secondWs, {
439+
id: "c-invalid-device-signature-2",
440+
token: "secret",
441+
device: secondDevice,
442+
});
443+
expect(second.ok).toBe(false);
444+
expect(second.error?.details?.code).toBe(ConnectErrorDetailCodes.AUTH_RATE_LIMITED);
445+
secondWs.close();
446+
});
447+
});
448+
401449
test("sends connect challenge on open", async () => {
402450
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
403451
const evtPromise: Promise<{

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

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

1010
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
1111
limiter: AuthRateLimiter;
12+
check: ReturnType<typeof vi.fn>;
13+
recordFailure: ReturnType<typeof vi.fn>;
1214
reset: ReturnType<typeof vi.fn>;
1315
} {
1416
const allowed = params?.allowed ?? true;
@@ -22,6 +24,8 @@ function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }
2224
reset,
2325
recordFailure,
2426
} as unknown as AuthRateLimiter,
27+
check,
28+
recordFailure,
2529
reset,
2630
};
2731
}
@@ -141,11 +145,14 @@ describe("resolveConnectAuthDecision", () => {
141145
});
142146

143147
it("accepts valid bootstrap tokens before device-token fallback", async () => {
148+
const rateLimiter = createRateLimiter();
144149
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
145150
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
146151
const decision = await resolveDeviceTokenDecision({
147152
verifyBootstrapToken,
148153
verifyDeviceToken,
154+
rateLimiter: rateLimiter.limiter,
155+
clientIp: "203.0.113.23",
149156
stateOverrides: {
150157
bootstrapTokenCandidate: "bootstrap-token",
151158
deviceTokenCandidate: "device-token",
@@ -154,10 +161,12 @@ describe("resolveConnectAuthDecision", () => {
154161
expect(decision.authOk).toBe(true);
155162
expect(decision.authMethod).toBe("bootstrap-token");
156163
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
164+
expect(rateLimiter.reset).toHaveBeenCalledWith("203.0.113.23", "bootstrap-token");
157165
expect(verifyDeviceToken).not.toHaveBeenCalled();
158166
});
159167

160168
it("reports invalid bootstrap tokens when no device token fallback is available", async () => {
169+
const rateLimiter = createRateLimiter();
161170
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
162171
ok: false,
163172
reason: "bootstrap_token_invalid",
@@ -166,6 +175,8 @@ describe("resolveConnectAuthDecision", () => {
166175
const decision = await resolveDeviceTokenDecision({
167176
verifyBootstrapToken,
168177
verifyDeviceToken,
178+
rateLimiter: rateLimiter.limiter,
179+
clientIp: "203.0.113.21",
169180
stateOverrides: {
170181
bootstrapTokenCandidate: "bootstrap-token",
171182
deviceTokenCandidate: undefined,
@@ -175,6 +186,31 @@ describe("resolveConnectAuthDecision", () => {
175186
expect(decision.authOk).toBe(false);
176187
expect(decision.authResult.reason).toBe("bootstrap_token_invalid");
177188
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
189+
expect(rateLimiter.check).toHaveBeenCalledWith("203.0.113.21", "bootstrap-token");
190+
expect(rateLimiter.recordFailure).toHaveBeenCalledWith("203.0.113.21", "bootstrap-token");
191+
expect(verifyDeviceToken).not.toHaveBeenCalled();
192+
});
193+
194+
it("returns rate-limited auth result without verifying bootstrap token", async () => {
195+
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
196+
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
197+
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
198+
const decision = await resolveDeviceTokenDecision({
199+
verifyBootstrapToken,
200+
verifyDeviceToken,
201+
rateLimiter: rateLimiter.limiter,
202+
clientIp: "203.0.113.22",
203+
stateOverrides: {
204+
bootstrapTokenCandidate: "bootstrap-token",
205+
deviceTokenCandidate: undefined,
206+
deviceTokenCandidateSource: undefined,
207+
},
208+
});
209+
210+
expect(decision.authOk).toBe(false);
211+
expect(decision.authResult.reason).toBe("rate_limited");
212+
expect(decision.authResult.retryAfterMs).toBe(60_000);
213+
expect(verifyBootstrapToken).not.toHaveBeenCalled();
178214
expect(verifyDeviceToken).not.toHaveBeenCalled();
179215
});
180216

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

Lines changed: 37 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,
@@ -158,23 +159,42 @@ export async function resolveConnectAuthDecision(params: {
158159

159160
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
160161
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
161-
const tokenCheck = await params.verifyBootstrapToken({
162-
deviceId: params.deviceId,
163-
publicKey: params.publicKey,
164-
token: bootstrapTokenCandidate,
165-
role: params.role,
166-
scopes: params.scopes,
167-
});
168-
if (tokenCheck.ok) {
169-
// Prefer an explicit valid bootstrap token even when another auth path
170-
// (for example tailscale serve header auth) already succeeded. QR pairing
171-
// relies on the server classifying the handshake as bootstrap-token so the
172-
// initial node pairing can be silently auto-approved and the bootstrap
173-
// token can be revoked after approval.
174-
authOk = true;
175-
authMethod = "bootstrap-token";
176-
} else if (!authOk) {
177-
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
162+
const bootstrapRateCheck = params.rateLimiter?.check(
163+
params.clientIp,
164+
AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN,
165+
);
166+
if (bootstrapRateCheck && !bootstrapRateCheck.allowed) {
167+
if (!authOk) {
168+
authResult = {
169+
ok: false,
170+
reason: "rate_limited",
171+
rateLimited: true,
172+
retryAfterMs: bootstrapRateCheck.retryAfterMs,
173+
};
174+
}
175+
} else {
176+
const tokenCheck = await params.verifyBootstrapToken({
177+
deviceId: params.deviceId,
178+
publicKey: params.publicKey,
179+
token: bootstrapTokenCandidate,
180+
role: params.role,
181+
scopes: params.scopes,
182+
});
183+
if (tokenCheck.ok) {
184+
// Prefer an explicit valid bootstrap token even when another auth path
185+
// (for example tailscale serve header auth) already succeeded. QR pairing
186+
// relies on the server classifying the handshake as bootstrap-token so the
187+
// initial node pairing can be silently auto-approved and the bootstrap
188+
// token can be revoked after approval.
189+
authOk = true;
190+
authMethod = "bootstrap-token";
191+
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
192+
} else {
193+
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN);
194+
if (!authOk) {
195+
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
196+
}
197+
}
178198
}
179199
}
180200

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ import {
5454
isWebchatClient,
5555
} from "../../../utils/message-channel.js";
5656
import { resolveRuntimeServiceVersion } from "../../../version.js";
57-
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
57+
import {
58+
AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE,
59+
type AuthRateLimiter,
60+
} from "../../auth-rate-limit.js";
5861
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
5962
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
6063
import {
@@ -767,6 +770,32 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
767770
}
768771
const rejectDeviceSignatureInvalid = () =>
769772
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
773+
const deviceSignatureRateCheck = authRateLimiter?.check(
774+
browserRateLimitClientIp,
775+
AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE,
776+
);
777+
if (deviceSignatureRateCheck && !deviceSignatureRateCheck.allowed) {
778+
setHandshakeState("failed");
779+
setCloseCause("device-auth-invalid", {
780+
reason: "device-signature-rate-limited",
781+
client: connectParams.client.id,
782+
deviceId: device.id,
783+
});
784+
send({
785+
type: "res",
786+
id: frame.id,
787+
ok: false,
788+
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature rate limited", {
789+
details: {
790+
code: ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
791+
retryAfterMs: deviceSignatureRateCheck.retryAfterMs,
792+
reason: "device-signature-rate-limited",
793+
},
794+
}),
795+
});
796+
close(1008, "device signature rate limited");
797+
return;
798+
}
770799
const payloadVersion = resolveDeviceSignaturePayloadVersion({
771800
device,
772801
connectParams,
@@ -776,9 +805,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
776805
nonce: providedNonce,
777806
});
778807
if (!payloadVersion) {
808+
authRateLimiter?.recordFailure(
809+
browserRateLimitClientIp,
810+
AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE,
811+
);
779812
rejectDeviceSignatureInvalid();
780813
return;
781814
}
815+
authRateLimiter?.reset(browserRateLimitClientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE);
782816
deviceAuthPayloadVersion = payloadVersion;
783817
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
784818
if (!devicePublicKey) {

0 commit comments

Comments
 (0)