From 0b0f81b7bb2fa6868b7435decad46d932efdb9f9 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 14:34:08 -0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20global=20login=20rate=20limit=20?= =?UTF-8?q?=E2=80=94=20XFF=20was=20spoofable=20and=20Map=20unbounded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/routes/auth.ts | 13 ++++++------- server/test/auth.test.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 8d5914b..cc2e3bd 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,7 +13,7 @@ const MAX_FAILURES = 10; export type AuthDeps = { db: Db }; export function authRoutes({ db }: AuthDeps) { - const failures = new Map(); + let failures = { count: 0, resetAt: 0 }; const app = new Hono(); const getHash = async () => @@ -39,19 +39,18 @@ export function authRoutes({ db }: AuthDeps) { }); app.post("/login", async (c) => { - const ip = c.req.header("x-forwarded-for") ?? "local"; - const f = failures.get(ip); - if (f && f.count >= MAX_FAILURES && Date.now() < f.resetAt) return c.json({ error: "too many attempts" }, 429); + const now = Date.now(); + if (now > failures.resetAt) failures = { count: 0, resetAt: now + WINDOW_MS }; + if (failures.count >= MAX_FAILURES) return c.json({ error: "too many attempts" }, 429); const body = PasswordBody.safeParse(await c.req.json().catch(() => null)); const hash = await getHash(); const ok = body.success && !!hash && (await Bun.password.verify(body.data.password, hash)); if (!ok) { - const cur = f && Date.now() < f.resetAt ? f : { count: 0, resetAt: Date.now() + WINDOW_MS }; - failures.set(ip, { count: cur.count + 1, resetAt: cur.resetAt }); + failures.count += 1; return c.json({ error: "invalid password" }, 401); } - failures.delete(ip); + failures.count = 0; const id = randomBytes(32).toString("base64url"); await db.insert(sessions).values({ id, createdAt: Date.now(), expiresAt: Date.now() + SESSION_MS }); setCookie(c, "helios_session", id, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: SESSION_MS / 1000 }); diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index ec11563..a0e3f55 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -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 () => {