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:
@@ -13,7 +13,7 @@ const MAX_FAILURES = 10;
|
|||||||
export type AuthDeps = { db: Db };
|
export type AuthDeps = { db: Db };
|
||||||
|
|
||||||
export function authRoutes({ db }: AuthDeps) {
|
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 app = new Hono();
|
||||||
|
|
||||||
const getHash = async () =>
|
const getHash = async () =>
|
||||||
@@ -39,19 +39,18 @@ export function authRoutes({ db }: AuthDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/login", async (c) => {
|
app.post("/login", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for") ?? "local";
|
const now = Date.now();
|
||||||
const f = failures.get(ip);
|
if (now > failures.resetAt) failures = { count: 0, resetAt: now + WINDOW_MS };
|
||||||
if (f && f.count >= MAX_FAILURES && Date.now() < f.resetAt) return c.json({ error: "too many attempts" }, 429);
|
if (failures.count >= MAX_FAILURES) return c.json({ error: "too many attempts" }, 429);
|
||||||
|
|
||||||
const body = PasswordBody.safeParse(await c.req.json().catch(() => null));
|
const body = PasswordBody.safeParse(await c.req.json().catch(() => null));
|
||||||
const hash = await getHash();
|
const hash = await getHash();
|
||||||
const ok = body.success && !!hash && (await Bun.password.verify(body.data.password, hash));
|
const ok = body.success && !!hash && (await Bun.password.verify(body.data.password, hash));
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const cur = f && Date.now() < f.resetAt ? f : { count: 0, resetAt: Date.now() + WINDOW_MS };
|
failures.count += 1;
|
||||||
failures.set(ip, { count: cur.count + 1, resetAt: cur.resetAt });
|
|
||||||
return c.json({ error: "invalid password" }, 401);
|
return c.json({ error: "invalid password" }, 401);
|
||||||
}
|
}
|
||||||
failures.delete(ip);
|
failures.count = 0;
|
||||||
const id = randomBytes(32).toString("base64url");
|
const id = randomBytes(32).toString("base64url");
|
||||||
await db.insert(sessions).values({ id, createdAt: Date.now(), expiresAt: Date.now() + SESSION_MS });
|
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 });
|
setCookie(c, "helios_session", id, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: SESSION_MS / 1000 });
|
||||||
|
|||||||
@@ -42,15 +42,18 @@ describe("auth", () => {
|
|||||||
expect((await app.request("/api/settings")).status).toBe(401);
|
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();
|
const app = makeApp();
|
||||||
await app.request("/api/setup", json({ password: "hunter2hunter2" }));
|
await app.request("/api/setup", json({ password: "hunter2hunter2" }));
|
||||||
const hdrs = { "content-type": "application/json", "x-forwarded-for": "10.9.8.7" };
|
|
||||||
let last = 0;
|
let last = 0;
|
||||||
for (let i = 0; i < 11; i++) {
|
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);
|
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 () => {
|
test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user