test(security): prove admin-login rate limit; make its toggle settable
The admin-login IP rate limit already existed (auth/handlers.rs: 5/min/IP, gated by
`rate_limits_enabled` + `admin_login_rate_enabled`, both default true in prod) — the earlier
"no rate-limit or lockout" note was wrong. What was missing was any TEST of it: the e2e
reseed sets `rate_limits_enabled=false`, so the old test observed all-401 under a disabled
limiter and "documented" the opposite of reality.
Replace that test with three that enable the limiter and prove it, mutation-verified
(deleting the check in the handler fails the first two):
- a burst of wrong passwords from one IP starts returning 429 (first is a normal 401, so
the password path ran and the limiter had budget; none is ever 200)
- once throttled, even the CORRECT password is refused — isolates the RATE LIMIT from the
password check (this is the assertion that dies if the limiter is removed)
- with `admin_login_rate_enabled=false` the same burst is NOT limited (the toggle gates it)
Two fixes this surfaced:
- `admin_login_rate_enabled` and `recover_rate_enabled` are read by their handlers but
were missing from patch_config's BOOL_KEYS allowlist — the switches existed in code and
could never be flipped. Added both.
- Test isolation: exhausting the admin-login limiter locks out the NEXT test's truncate
fixture, which must itself call admin_login before it can reset anything. An afterEach
disables the toggle via patchConfig (JWT-based, not rate-limited) so the next login is
clear; truncate then wipes the counter map. Runs on failure too.
Full desktop suite 201 passed / 1 skipped; backend 56 tests; clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,11 @@ pub async fn patch_config(
|
|||||||
"feed_rate_enabled",
|
"feed_rate_enabled",
|
||||||
"export_rate_enabled",
|
"export_rate_enabled",
|
||||||
"join_rate_enabled",
|
"join_rate_enabled",
|
||||||
|
// These two per-area rate toggles are HONOURED by their handlers (auth/handlers.rs reads
|
||||||
|
// `admin_login_rate_enabled` and `recover_rate_enabled`, both defaulting true) but were
|
||||||
|
// missing from this allowlist — so the switch existed in code and could never be flipped.
|
||||||
|
"admin_login_rate_enabled",
|
||||||
|
"recover_rate_enabled",
|
||||||
"quota_enabled",
|
"quota_enabled",
|
||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_quota_enabled",
|
"upload_count_quota_enabled",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* tampered signature, expired sessions, wrong role.
|
* tampered signature, expired sessions, wrong role.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
@@ -144,20 +145,96 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Adversarial — admin password brute-force', () => {
|
test.describe('Adversarial — admin password brute-force', () => {
|
||||||
test('repeated wrong passwords do NOT lock the admin (documented finding)', async () => {
|
// These tests deliberately exhaust the admin-login limiter for the shared test IP. That creates a
|
||||||
// The admin login handler does not currently implement lockout. This test
|
// chicken-and-egg for the NEXT test: its truncate auto-fixture must itself call admin_login before
|
||||||
// documents the behavior so any future change is intentional.
|
// it can reset the counter — so a still-full window would lock the fixture out with 429 before it
|
||||||
const attempts = await Promise.all(
|
// could clear anything. Disable the toggle here via patchConfig (which authenticates with a JWT,
|
||||||
Array.from({ length: 10 }, () =>
|
// NOT the rate-limited admin_login path), so the next login is clear; truncate then wipes the map.
|
||||||
|
// Runs even if the test body failed, so a failure can't poison the rest of the run.
|
||||||
|
test.afterEach(async ({ api, adminToken }) => {
|
||||||
|
await api.patchConfig(adminToken, { admin_login_rate_enabled: 'false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const tryLogin = (password: string) =>
|
||||||
fetch(`${BASE}/api/v1/admin/login`, {
|
fetch(`${BASE}/api/v1/admin/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password: 'wrong-' + Math.random() }),
|
body: JSON.stringify({ password }),
|
||||||
})
|
});
|
||||||
)
|
|
||||||
);
|
test('admin login is IP rate-limited: a burst of wrong passwords starts returning 429', async ({
|
||||||
const statuses = attempts.map((r) => r.status);
|
api,
|
||||||
expect(statuses.every((s) => s === 401)).toBe(true);
|
adminToken,
|
||||||
console.warn('[finding] /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.');
|
}) => {
|
||||||
|
// `admin_login` HAS a 5/min/IP throttle (auth/handlers.rs), gated by the master
|
||||||
|
// `rate_limits_enabled` + `admin_login_rate_enabled`. Both default TRUE in production — but the
|
||||||
|
// e2e reseed turns the master switch OFF for every test, so this defence ran in ZERO tests.
|
||||||
|
// (The previous version of this test observed all-401 under that disabled switch and wrongly
|
||||||
|
// "documented" that no rate limit exists.) Turn it on and prove it.
|
||||||
|
//
|
||||||
|
// patchConfig uses the already-minted adminToken (a JWT), so enabling the limiter does not lock
|
||||||
|
// us out of configuring it.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
admin_login_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sequential, not parallel: the counter increments deterministically so the 429 is not itself
|
||||||
|
// subject to the counter race the PIN test covers.
|
||||||
|
const statuses: number[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
statuses.push((await tryLogin('wrong-' + i)).status);
|
||||||
|
if (statuses[i] === 429) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password path actually ran (budget existed) before the limiter engaged...
|
||||||
|
expect(statuses[0], 'first attempt should be a normal wrong-password 401, not a spurious 429').toBe(401);
|
||||||
|
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
||||||
|
expect(
|
||||||
|
statuses.some((s) => s === 429),
|
||||||
|
'a burst of wrong admin passwords from one IP must start being rate-limited (429)'
|
||||||
|
).toBe(true);
|
||||||
|
// No wrong password ever authenticated.
|
||||||
|
expect(statuses.some((s) => s === 200)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
|
||||||
|
// password logic. If the throttle were removed, the correct password would return 200 here.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
admin_login_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exhaust the window with wrong passwords until throttled.
|
||||||
|
let throttled = false;
|
||||||
|
for (let i = 0; i < 10 && !throttled; i++) {
|
||||||
|
throttled = (await tryLogin('wrong-' + i)).status === 429;
|
||||||
|
}
|
||||||
|
expect(throttled, 'the IP should be throttled after a burst').toBe(true);
|
||||||
|
|
||||||
|
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||||
|
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||||
|
expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
// The mirror of the above — proves the toggle actually gates the behaviour (and documents that
|
||||||
|
// the e2e default really is "off", which is why every OTHER admin test can hammer login freely).
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
admin_login_rate_enabled: 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses: number[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
||||||
|
|
||||||
|
expect(statuses.every((s) => s === 401), 'with admin_login_rate_enabled=false no attempt should be 429').toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user