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

@@ -13,7 +13,7 @@ const MAX_FAILURES = 10;
export type AuthDeps = { db: Db };
export function authRoutes({ db }: AuthDeps) {
const failures = new Map<string, { count: number; resetAt: number }>();
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 });

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 () => {