fix(auth): harden async login boundaries
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user