Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions server/verifyTokenAndRateLimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 16 additions & 12 deletions server/verifyTokenAndRateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}