fix(gateway): prevent browser rate-limit self-DoS on missing credentials

Stop counting missing credentials (token_missing, password_missing) as
rate-limit failures — a misconfigured browser is not a brute-force attack.
Stop auto-reconnecting on non-recoverable auth errors (missing token,
missing password, wrong password, rate-limited). Preserve device-token
fallback by allowing reconnect on token mismatch.

Includes unit tests for both fixes (12 new tests, all passing).

Continuation of #36138 (closed due to unrelated changes drifting in).
This commit is contained in:
ademczuk
2026-03-07 10:42:48 +01:00
parent c934dd51c0
commit 533ff3e70b
4 changed files with 139 additions and 4 deletions

View File

@@ -5,7 +5,10 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js";
import { readConnectErrorDetailCode } from "../../../src/gateway/protocol/connect-error-details.js";
import {
ConnectErrorDetailCodes,
readConnectErrorDetailCode,
} from "../../../src/gateway/protocol/connect-error-details.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
import { generateUUID } from "./uuid.ts";
@@ -50,6 +53,29 @@ export function resolveGatewayErrorDetailCode(
return readConnectErrorDetailCode(error?.details);
}
/**
* Auth errors that won't resolve without user action — don't auto-reconnect.
*
* NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the
* browser client has a device-token fallback flow: a stale cached device token
* triggers a mismatch, sendConnect() clears it, and the next reconnect retries
* with opts.token (the shared gateway token). Blocking reconnect on mismatch
* would break that fallback. The rate limiter still catches persistent wrong
* tokens after N failures → AUTH_RATE_LIMITED stops the loop.
*/
export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean {
if (!error) {
return false;
}
const code = resolveGatewayErrorDetailCode(error);
return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED
);
}
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
@@ -135,7 +161,9 @@ export class GatewayBrowserClient {
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason, error: connectError });
this.scheduleReconnect();
if (!isNonRecoverableAuthError(connectError)) {
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", () => {
// ignored; close handler will fire