Exempt admins and super viewers from auth rate limits
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-12 22:01:03 -04:00
parent 0a98ea319c
commit 95160de5b4
3 changed files with 127 additions and 10 deletions
+97
View File
@@ -578,6 +578,103 @@ describe('forced password changes and asynchronous authorization races', () => {
});
describe('persistent authentication throttling', () => {
it.each(['admin', 'super_viewer'] as const)(
'does not consume login or password-change limits for an enabled %s',
(role) => {
const user = seedUser('unlimited', role);
for (let attempt = 0; attempt < 31; attempt++) {
auth.consumeAuthAttempt('192.0.2.50', user.username.toUpperCase());
auth.consumeAuthAttempt('192.0.2.50', user.id, 'password');
}
expect(database.db().prepare('SELECT * FROM auth_rate_limits').all()).toEqual([]);
// Initial setup cannot inherit an existing account's exemption.
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup');
expect(() => auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup')).toThrow(
'Too many attempts'
);
}
);
it.each(['admin', 'super_viewer'] as const)(
'allows %s login and password changes through exhausted account and address limits',
async (role) => {
const actor = seedUser();
const user = seedUser('promoted', 'viewer', { mustChangePassword: true });
const address = '192.0.2.51';
for (const kind of ['login', 'password'] as const) {
const account = kind === 'login' ? user.username : user.id;
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt(address, account, kind);
for (let attempt = 0; attempt < 20; attempt++)
auth.consumeAuthAttempt(address, `missing-${attempt}`, kind);
}
const limits = database
.db()
.prepare('SELECT * FROM auth_rate_limits ORDER BY key')
.all();
await auth.updateUser(actor.id, user.id, { role });
await expect(
auth.authenticatePassword(
{ username: user.username, password: replacement },
address
)
).rejects.toMatchObject({ status: 401 });
const login = await auth.authenticatePassword(
{ username: user.username.toUpperCase(), password },
address
);
expect(login.user).toMatchObject({ id: user.id, role, mustChangePassword: true });
expect(auth.readSession(login.session.token)?.user.id).toBe(user.id);
const { POST } = await import('../../src/routes/api/auth/password/+server.js');
const url = new URL('https://ec.test/api/auth/password');
const response = await POST({
request: new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: password, newPassword: replacement })
}),
cookies: { set: vi.fn() },
locals: { user: login.user },
url,
getClientAddress: () => address
} as unknown as Parameters<typeof POST>[0]);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
user: { id: user.id, role, mustChangePassword: false }
});
expect(
database.db().prepare('SELECT * FROM auth_rate_limits ORDER BY key').all()
).toEqual(limits);
}
);
it.each([
['viewer', false],
['operator', false],
['admin', true],
['super_viewer', true]
] as const)('retains limits for %s with disabled=%s', async (role, disabled) => {
const user = seedUser('limited', role, { disabled });
for (const kind of ['login', 'password'] as const) {
const account = kind === 'login' ? user.username : user.id;
for (let attempt = 0; attempt < 10; attempt++)
auth.consumeAuthAttempt('192.0.2.52', account, kind);
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
'Too many attempts'
);
}
await expect(
auth.authenticatePassword(
{ username: user.username, password, role: 'admin' },
'192.0.2.53'
)
).rejects.toMatchObject({ status: 429 });
});
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
let now = 2_000_000_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);