diff --git a/server/verifyTokenAndRateLimit.test.ts b/server/verifyTokenAndRateLimit.test.ts index dc5ca7aa..1fc9ac08 100644 --- a/server/verifyTokenAndRateLimit.test.ts +++ b/server/verifyTokenAndRateLimit.test.ts @@ -105,6 +105,41 @@ describe("verifyTokenAndRateLimit", () => { expect(result.error).toBe("Too many requests."); }); + it("should rate-limit invalid tokens instead of skipping the limiter", async () => { + mockArgon2VerifyResult = false; + mockRateLimiterShouldFail = true; + vi.resetModules(); + const { verifyTokenAndRateLimit } = await import( + "./verifyTokenAndRateLimit" + ); + const result = await verifyTokenAndRateLimit("invalid-token"); + expect(result.statusCode).toBe(429); + expect(result.error).toBe("Too many requests."); + }); + + it("should not run argon2 verification when the request is already rate limited", async () => { + mockRateLimiterShouldFail = true; + vi.resetModules(); + const { verifyTokenAndRateLimit } = await import( + "./verifyTokenAndRateLimit" + ); + const hashWasm = await import("hash-wasm"); + const result = await verifyTokenAndRateLimit("some-token"); + expect(result.statusCode).toBe(429); + expect(hashWasm.argon2Verify).not.toHaveBeenCalled(); + }); + + it("should rate-limit requests with a missing token", async () => { + mockRateLimiterShouldFail = true; + vi.resetModules(); + const { verifyTokenAndRateLimit } = await import( + "./verifyTokenAndRateLimit" + ); + const result = await verifyTokenAndRateLimit(null); + expect(result.statusCode).toBe(429); + expect(result.error).toBe("Too many requests."); + }); + it("should key rate limiter on the socket address by default (untrusted proxy)", async () => { mockRateLimiterShouldFail = false; vi.resetModules(); diff --git a/server/verifyTokenAndRateLimit.ts b/server/verifyTokenAndRateLimit.ts index 1335f7fe..2a6d5eef 100644 --- a/server/verifyTokenAndRateLimit.ts +++ b/server/verifyTokenAndRateLimit.ts @@ -59,6 +59,22 @@ export async function verifyTokenAndRateLimit( statusCode?: number; error?: string; }> { + // Rate-limit before anything else. An invalid or missing token used to skip + // the limiter entirely, and every rejected request still pays for a full + // argon2 verification, which is expensive enough to be a DoS lever on a + // publicly reachable instance: a caller could send an endless stream of + // bogus tokens and pin the server's CPU without ever hitting the limiter. + const rateLimitKey = request ? getClientIp(request) : (token ?? "anonymous"); + try { + await rateLimiter.consume(rateLimitKey); + } catch { + return { + isAuthorized: false, + statusCode: 429, + error: "Too many requests.", + }; + } + if (!token) { return { isAuthorized: false, @@ -91,17 +107,5 @@ export async function verifyTokenAndRateLimit( // Records a new session or refreshes an active one's last-seen time. addVerifiedToken(token); - const rateLimitKey = request ? getClientIp(request) : token; - - try { - await rateLimiter.consume(rateLimitKey); - } catch { - return { - isAuthorized: false, - statusCode: 429, - error: "Too many requests.", - }; - } - return { isAuthorized: true }; }