Files
helios/server/test/auth.test.ts
marcuspaico 0b0f81b7bb 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.
2026-08-17 14:34:08 -07:00

74 lines
3.0 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
function makeApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) });
}
const json = (body: unknown) => ({
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
describe("auth", () => {
test("fresh instance needs setup; setup then login yields a session", async () => {
const app = makeApp();
let me = await (await app.request("/api/me")).json();
expect(me).toEqual({ needsSetup: true, authenticated: false });
expect((await app.request("/api/setup", json({ password: "hunter2hunter2" }))).status).toBe(204);
expect((await app.request("/api/setup", json({ password: "again-not-allowed" }))).status).toBe(400);
const login = await app.request("/api/login", json({ password: "hunter2hunter2" }));
expect(login.status).toBe(204);
const cookie = login.headers.get("set-cookie")!;
expect(cookie).toContain("helios_session=");
expect(cookie).toContain("HttpOnly");
me = await (await app.request("/api/me", { headers: { cookie } })).json();
expect(me.authenticated).toBe(true);
});
test("wrong password 401; protected route 401 without cookie", async () => {
const app = makeApp();
await app.request("/api/setup", json({ password: "hunter2hunter2" }));
expect((await app.request("/api/login", json({ password: "wrong-wrong-1" }))).status).toBe(401);
expect((await app.request("/api/settings")).status).toBe(401);
});
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" }));
let last = 0;
for (let i = 0; i < 11; i++) {
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 () => {
const app = makeApp();
await app.request("/api/setup", json({ password: "hunter2hunter2" }));
// Unauthed request to unknown endpoint returns 401
expect((await app.request("/api/nope")).status).toBe(401);
// Authed request to unknown endpoint returns 404
const login = await app.request("/api/login", json({ password: "hunter2hunter2" }));
const cookie = login.headers.get("set-cookie")!;
const res = await app.request("/api/nope", { headers: { cookie } });
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "not found" });
});
});