Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was guarded as meant. Fixed, each with a test: - login: a directory 503 counts as a failed sign-in; a locked directory account is a refused password, not "unavailable" - open redirect after OpenID sign-in (a tab in next) - directory email matching only for non-admins without their own password; others connect on My settings (POST /api/me/identities/ldap); only admins disconnect a directory account - OpenID session check reads the refreshed ID token's claims too - connect is POST /api/auth/oidc/connect (CSRF header) - stored bind password, client secret and SMTP password tied to their server - 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400 - @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a password asked again shares the login limiter Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
72 lines
2.5 KiB
TypeScript
72 lines
2.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// 08 §2.4: every route is rate limited per client IP, Test buttons more, and a signed-in
|
|
// person's password asked again counts like a sign-in.
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe.skipIf(!hasTestDb)('rate limits (08 §2.4)', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let admin: string;
|
|
beforeAll(async () => {
|
|
t = await createTestApp(undefined, { RATE_LIMIT_PER_MINUTE: '30' });
|
|
await t.clearMustChange(ADMIN.email);
|
|
admin = await t.login(ADMIN.email, ADMIN.password);
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
it('Test buttons: 10 a minute', async () => {
|
|
const codes: number[] = [];
|
|
for (let i = 0; i < 11; i++)
|
|
codes.push(
|
|
(
|
|
await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/settings/smtp/test',
|
|
headers: { ...H, cookie: admin },
|
|
payload: {},
|
|
})
|
|
).statusCode,
|
|
);
|
|
expect(codes.slice(0, 10)).not.toContain(429);
|
|
expect(codes[10]).toBe(429);
|
|
});
|
|
|
|
it('a password asked again counts like a sign-in: guessing it is stopped', async () => {
|
|
resetLoginLimiter();
|
|
const codes: number[] = [];
|
|
for (let i = 0; i < 6; i++)
|
|
codes.push(
|
|
(
|
|
await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/change-password',
|
|
headers: { ...H, cookie: admin },
|
|
payload: { currentPassword: `guess-${i}-wrong`, newPassword: 'another-password-1' },
|
|
})
|
|
).statusCode,
|
|
);
|
|
expect(codes.slice(0, 5)).toEqual([400, 400, 400, 400, 400]);
|
|
expect(codes[5]).toBe(429);
|
|
resetLoginLimiter();
|
|
});
|
|
|
|
it('/prov keeps its own limits (07 §2), outside the global one', async () => {
|
|
const prov = await t.app.inject({ url: '/prov/001565000000.cfg' });
|
|
expect(prov.headers['x-ratelimit-limit']).toBeUndefined();
|
|
const api = await t.app.inject({ url: '/api/auth/methods' });
|
|
expect(api.headers['x-ratelimit-limit']).toBe('30');
|
|
});
|
|
|
|
it('every other route: the global limit per IP, as a 429 rate_limited', async () => {
|
|
let r;
|
|
for (let i = 0; i < 40; i++) {
|
|
r = await t.app.inject({ url: '/api/auth/methods' });
|
|
if (r.statusCode === 429) break;
|
|
}
|
|
expect(r!.statusCode).toBe(429);
|
|
expect(r!.json().error.code).toBe('rate_limited');
|
|
});
|
|
});
|