fix(auth): harden async login boundaries

This commit is contained in:
2026-08-17 00:00:34 +02:00
parent 29bfb41363
commit c34e01e9b9
8 changed files with 426 additions and 124 deletions
+74
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { LoginFailureLimiter } from "../src/auth/routes.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
@@ -270,6 +271,66 @@ test("failed logins are limited by normalized username and source address", asyn
expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429);
});
test("failed-attempt limiter keeps exact bounded windows without check-time mutation or active-key eviction", () => {
const now = 1_000_000;
const limiter = new LoginFailureLimiter({ maximumEntries: 2 });
expect(limiter.isLimited("checked-only", "127.0.0.1", now)).toBe(false);
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("attacker", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("flood", "127.0.0.1", now)).toBe(false);
for (let attempt = 1; attempt < 10; attempt += 1) {
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
}
expect(limiter.isLimited("victim", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(false);
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000 - 1)).toBe(true);
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(false);
expect(limiter.recordFailure("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(true);
});
test("failed-attempt limiter allows twenty source-address failures, then rejects the twenty-first", () => {
const limiter = new LoginFailureLimiter();
const now = 1_000_000;
for (let attempt = 0; attempt < 20; attempt += 1) {
expect(limiter.recordFailure(`user-${attempt}`, "127.0.0.1", now)).toBe(true);
}
expect(limiter.isLimited("new-user", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("new-user", "127.0.0.1", now)).toBe(false);
});
test("successful and pre-authentication failures never reset or consume failed-login counters", async () => {
let calls = 0;
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const { app } = await createLocalApp({
registry: {
findByUsername: async () => user,
findBySubject: async () => user,
verify: async () => {
calls += 1;
return calls === 10;
},
},
});
const originRejected = await app.inject({
method: "POST", url: "/auth/local/login", headers: { origin: "http://wrong.example.test" },
payload: { username: "Admin", password },
});
expect(originRejected.statusCode).toBe(403);
expect(calls).toBe(0);
for (let attempt = 0; attempt < 9; attempt += 1) expect((await login(app)).statusCode).toBe(401);
expect((await login(app)).statusCode).toBe(200);
expect((await login(app)).statusCode).toBe(401);
expect((await login(app)).statusCode).toBe(429);
expect((await login(app)).statusCode).toBe(429);
});
test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => {
let calls = 0;
let release!: () => void;
@@ -302,6 +363,19 @@ test("only two Argon2 verifications run concurrently and excess login attempts f
expect((await second).statusCode).toBe(401);
});
test("the real native asynchronous Argon2 verifier holds two permits and releases them after completion", async () => {
const { app } = await createLocalApp();
const first = login(app, { password: `${password}!` });
const second = login(app, { password: `${password}!` });
await new Promise<void>((resolve) => setImmediate(resolve));
const excess = await login(app, { password: `${password}!` });
expect(excess.statusCode).toBe(429);
await expect(first).resolves.toMatchObject({ statusCode: 401 });
await expect(second).resolves.toMatchObject({ statusCode: 401 });
await expect(login(app, { password: `${password}!` })).resolves.toMatchObject({ statusCode: 401 });
});
test("a verifier failure is sanitized and releases its concurrency permit", async () => {
let attempts = 0;
const user = {