fix(auth): global login rate limit — XFF was spoofable and Map unbounded

The login rate limiter keyed on the client-controlled x-forwarded-for
header, letting an attacker rotate XFF for unlimited password guesses
while also growing the failures Map unboundedly (memory DoS). Since
this is a single-password instance, replace with one global
{count, resetAt} tracker per app instance: check the 15-min window and
reject at >=10 failures before verifying the password, increment on
failure, reset on success.
This commit is contained in:
marcuspaico
2026-08-17 14:34:08 -07:00
parent 0ac233945c
commit 0b0f81b7bb
2 changed files with 12 additions and 10 deletions

View File

@@ -42,15 +42,18 @@ describe("auth", () => {
expect((await app.request("/api/settings")).status).toBe(401);
});
test("11th failed login from one IP is rate-limited", async () => {
test("11th failed login is globally rate-limited, even for the correct password", async () => {
const app = makeApp();
await app.request("/api/setup", json({ password: "hunter2hunter2" }));
const hdrs = { "content-type": "application/json", "x-forwarded-for": "10.9.8.7" };
let last = 0;
for (let i = 0; i < 11; i++) {
last = (await app.request("/api/login", { method: "POST", headers: hdrs, body: JSON.stringify({ password: "wrong-wrong-1" }) })).status;
last = (await app.request("/api/login", json({ password: "wrong-wrong-1" }))).status;
}
expect(last).toBe(429);
// The global window applies to everyone, including a request with the correct password.
const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" }));
expect(blocked.status).toBe(429);
});
test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => {