mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
1 parent
ebb9e00de9
commit
175e3b8320
12 files changed
+61
-35
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in new issue
Block a user