fix(auth): keep the single-user login throttle at 10, widen only the multi-user pre-gate

Single-user mode has no per-username bucket, so its per-IP bucket is the
only limit on guesses against the one password, and behind a tunnel every
client shares 127.0.0.1. Put it back on AUTH_FAILURE_MAX (10).

AUTH_IP_FAILURE_MAX (50) now gates only the multi-user per-IP pre-gate,
which runs before the scrypt verify and so refuses even a correct password;
the per-username bucket still caps each account at 10.

Move the headroom test out of the single-user suite and add a multi-user
test that sprays wrong passwords across usernames (9 each) and expects 401
for the first 50, then 429 for the 51st and for a correct password.

Update the docs that still described 10 per IP for every mode, and resync
the plugin mirror of the codeman skill.
This commit is contained in:
Aamer Akhter committed 2026-10-22 21:21:49 -04:00
1 parent ebb9e00de9
commit 175e3b8320
12 files changed
+61 -35

No files matched your search

+2 -16
View File
@@ -15,7 +15,7 @@ import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX, AUTH_IP_FAILURE_MAX } from '../src/config/auth-config.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -47,7 +47,7 @@ async function getSessionCookie(baseUrl: string): Promise<string> {
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < AUTH_IP_FAILURE_MAX; i++) {
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
@@ -208,20 +208,6 @@ describe('Auth Security', () => {
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
});
it('leaves per-IP headroom above the per-account cap before throttling', async () => {
// One egress IP (NAT, tunnel, an automated client) can carry more failures
// than one person does, so the per-IP login bucket is wider than the
// per-username one.
expect(AUTH_IP_FAILURE_MAX).toBe(50);
expect(AUTH_IP_FAILURE_MAX).toBeGreaterThan(AUTH_FAILURE_MAX);
for (let i = 0; i <= AUTH_FAILURE_MAX; i++) {
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `ip-headroom-${i}`) },
});
expect(res.status).toBe(401);
}
});
it('should allow an existing valid session cookie during auth failure lockout', async () => {
const cookie = await getSessionCookie(rateBaseUrl);
await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie');
+27
View File
@@ -33,6 +33,7 @@ function cookieFrom(res: Response): string | null {
let server: WebServer;
let rateServer: WebServer;
let sprayServer: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
@@ -72,6 +73,7 @@ beforeAll(async () => {
afterAll(async () => {
await server?.stop();
await rateServer?.stop().catch(() => {});
await sprayServer?.stop().catch(() => {});
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
@@ -184,6 +186,31 @@ describe('multi-user auth', () => {
}
expect(limited).toBe(true);
});
it('per-IP pre-gate: wrong passwords spread across accounts are throttled at 50, before scrypt', async () => {
// Fresh server so no earlier test's failures sit in the per-IP bucket.
sprayServer = new WebServer(0, false, true);
await sprayServer.start();
const surl = (p: string) => `http://localhost:${sprayServer.boundPort}${p}`;
// Spread the guesses so no single username reaches its own AUTH_FAILURE_MAX bucket:
// only the per-IP pre-gate can produce a 429 here.
const perUser = AUTH_FAILURE_MAX - 1;
for (let i = 0; i < 50; i++) {
const user = `spray${Math.floor(i / perUser)}`;
const res = await fetch(surl('/api/status'), { headers: { Authorization: basic(user, `bad-${i}`) } });
expect(res.status, `attempt ${i + 1}`).toBe(401);
}
// The 51st failure from the same IP is refused by the pre-gate.
const over = await fetch(surl('/api/status'), { headers: { Authorization: basic('spray-extra', 'bad') } });
expect(over.status).toBe(429);
expect(over.headers.get('retry-after')).toMatch(/^\d+$/);
// The pre-gate runs before the password check, so even a correct password is refused.
const good = await fetch(surl('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
expect(good.status).toBe(429);
});
});
describe('QR token identity (tunnel-manager)', () => {