From 175e3b8320912fcc87c6ec012953ec08a0f018e2 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 21:21:49 -0400 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- README.md | 4 +-- README.zh-CN.md | 4 +-- docs/architecture-invariants.md | 2 +- docs/security-architecture.md | 5 +++- docs/wiki/Security.md | 2 +- .../skills/codeman/reference/endpoints.md | 7 +++-- skills/codeman/reference/endpoints.md | 7 +++-- src/config/auth-config.ts | 12 +++++---- src/web/middleware/auth.ts | 6 +++-- test/auth-security.test.ts | 18 ++----------- test/multiuser-auth.test.ts | 27 +++++++++++++++++++ 12 files changed, 61 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0f..23e22eda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,7 +274,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **CSRF / Origin** | Always-on cross-site Origin guard on state-changing requests. **A missing Origin is allowed** so curl/CLI and hooks keep working. ⚠️ The body parser keeps `text/plain` RAW; auto-JSON-parsing it enabled simple-request CSRF | | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | -| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login | +| **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead allows 50/IP before the password check (`AUTH_IP_FAILURE_MAX`) plus 10 per username. QR and hook-secret have separate buckets, so neither can lock out login | | **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) | | **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` | | **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) | diff --git a/README.md b/README.md index b3fe7e7e..3cbb3e77 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ loginctl enable-linger $USER 1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`) 2. On success → server issues a `codeman_session` cookie (24h TTL, auto-extends on activity) 3. Subsequent requests authenticate silently via cookie -4. 10 failed attempts per IP → 429 rate limit (15-minute decay) +4. 10 failed attempts per IP → 429 rate limit (15-minute decay). In multi-user mode the per-IP cap is 50, and each username is capped at 10 failed guesses **Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions. @@ -670,7 +670,7 @@ By default Codeman launches sessions with `--dangerously-skip-permissions`, so t - **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`) - **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log) -- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter +- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. Multi-user mode allows 50 failures per IP instead (checked before the password, so past it a correct password also waits for the decay), plus 10 failed guesses per username. QR auth has its own separate limiter - **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Agents & CLIs → Claude → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant ### Always-on browser hardening (v0.9.5) diff --git a/README.zh-CN.md b/README.zh-CN.md index e70bd7a2..ae298d08 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -581,7 +581,7 @@ loginctl enable-linger $USER 1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`) 2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长) 3. 后续请求通过 cookie 静默认证 -4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减) +4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)。多用户模式下每个 IP 的上限为 50 次,每个用户名另有 10 次失败上限 通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。 @@ -671,7 +671,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI - **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认) - **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志) -- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器 +- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。多用户模式下每个 IP 改为 50 次(在校验密码之前检查,超过后正确密码也须等待衰减),每个用户名另有 10 次失败上限。二维码认证有自己独立的限制器 - **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权 ### 始终开启的浏览器加固(v0.9.5) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0cabeaf7..96c339a2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1163,7 +1163,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u | **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | -| **Rate limit** | 50 failed logins/IP (`AUTH_IP_FAILURE_MAX`) and 10 per username → 429 (15min decay). QR has separate limiter | +| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has a 50/IP pre-gate (`AUTH_IP_FAILURE_MAX`, checked before scrypt) plus a 10-per-username bucket (429 from the 10th failed guess). QR has separate limiter | | **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | | **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | diff --git a/docs/security-architecture.md b/docs/security-architecture.md index a7e50c04..21aefee7 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -149,7 +149,7 @@ by presence in a server‑side map, so they cannot be forged offline. ### Rate limiting / lockout recovery -Failed logins are tracked **per IP**: 50 failures (`AUTH_IP_FAILURE_MAX`) → `429`, with a 15‑minute decay. In multi-user mode a separate **per-username** bucket still returns `429` after 10 failed guesses (`AUTH_FAILURE_MAX`), so the wider per-IP cap (for clients sharing one egress IP) does not widen guessing against any one account. +Failed logins are tracked **per IP**: 10 failures (`AUTH_FAILURE_MAX`) → `429`, with a 15‑minute decay. Multi-user mode replaces that with a wider per-IP **pre-gate** of 50 failures (`AUTH_IP_FAILURE_MAX`), checked before the scrypt verify, plus a **per-username** bucket that exists in multi-user mode only and answers `429` from the 10th failed guess (`AUTH_FAILURE_MAX`; the count is incremented before the check), so the wider per-IP cap (for users sharing one egress IP) does not widen guessing against any one account. The QR path has its own separate limiter. The lockout check sits **after** the cookie/credential checks (step 4, not first). @@ -158,6 +158,9 @@ immediately** even while an attacker is hammering the same IP — important beca all traffic through a tunnel shares one source IP (loopback). Wrong credentials are still counted and still hit the `429` at the threshold, so brute‑force protection is unchanged. +The multi-user per-IP pre-gate is the exception: it runs before the password is +verified, so once it trips even a correct password gets `429` until the decay, +which is why its cap is wider than the per-username one. --- diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 71b45c2a..77dff864 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -53,7 +53,7 @@ When authentication is enabled: | ------------------- | ------------------------------------------------------------------------------------------------ | | **HTTP Basic** | `CODEMAN_USERNAME` (default `admin`) and `CODEMAN_PASSWORD`. | | **Session cookie** | A 256-bit opaque token validated server side, so it cannot be forged offline. 24 hours, extended on activity, with a device-context audit trail. | -| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. | +| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. In multi-user mode the per-IP cap is 50 and is checked before the password, so past it a correct password also waits for the decay; each username is also capped at ten failed guesses. | | **QR auth** | Single-use 60-second tokens with their own separate rate limiter, so a mistyped password cannot lock out QR login. | | **Hook endpoints** | The hook and telemetry endpoints skip Basic auth because they are called from localhost by the CLI, but when auth is on, that bypass additionally requires a per-instance hook secret. | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 3edf4f70..ca12ff97 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -35,7 +35,10 @@ only and can never drive a session. with a parse error and `.errorCode` is simply absent (see [symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15 -minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a +minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user +mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and is checked before the password, +so past it even a correct password gets the 429; each username is also capped at 10 +failed guesses. **Never retry a failing credential in a loop**: you will lock the address out of the login path for everything, including the user's browser through a tunnel (tunneled traffic arrives as 127.0.0.1, so one bucket covers it all). @@ -801,7 +804,7 @@ response echoes. | sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | -| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | +| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per username) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | Case creation is **uncapped**, which is the one place restraint has to come from you: every `quick-start` with a new `caseName` creates a real directory on the user's disk. diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 3edf4f70..ca12ff97 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -35,7 +35,10 @@ only and can never drive a session. with a parse error and `.errorCode` is simply absent (see [symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15 -minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a +minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user +mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and is checked before the password, +so past it even a correct password gets the 429; each username is also capped at 10 +failed guesses. **Never retry a failing credential in a loop**: you will lock the address out of the login path for everything, including the user's browser through a tunnel (tunneled traffic arrives as 127.0.0.1, so one bucket covers it all). @@ -801,7 +804,7 @@ response echoes. | sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | -| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | +| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per username) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | Case creation is **uncapped**, which is the one place restraint has to come from you: every `quick-start` with a new `caseName` creates a real directory on the user's disk. diff --git a/src/config/auth-config.ts b/src/config/auth-config.ts index c9ade053..e61dfc7c 100644 --- a/src/config/auth-config.ts +++ b/src/config/auth-config.ts @@ -23,15 +23,17 @@ export const MAX_AUTH_SESSIONS = 100; /** * Max failed auth attempts per bucket before 429 rejection. Applies to the - * per-username lockout and the hook-secret bucket. + * single-user per-IP bucket, the multi-user per-username lockout and the + * hook-secret bucket. */ export const AUTH_FAILURE_MAX = 10; /** - * Max failed login attempts per client IP before 429 rejection. Wider than - * AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel, an automated client) can - * carry many users' or retries' failures; the per-username lockout above still - * caps guesses against any one account. + * Max failed login attempts per client IP for the multi-user pre-gate, which + * runs before the scrypt verify and so refuses even a correct password once + * reached. Wider than AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can + * carry many users' failures; the per-username bucket still caps guesses against + * each account at AUTH_FAILURE_MAX. Single-user mode does not use this. */ export const AUTH_IP_FAILURE_MAX = 50; diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 44660ee7..767da950 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -448,7 +448,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas // Rate limit only requests that failed to authenticate on this attempt. const failures = authFailures.get(clientIp) ?? 0; - if (failures >= AUTH_IP_FAILURE_MAX) { + if (failures >= AUTH_FAILURE_MAX) { sendAuthRateLimit(reply, authFailures, clientIp); return; } @@ -572,7 +572,9 @@ function registerMultiUserAuthHook( // 2. Basic Auth against the user store (scrypt verify). // Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the - // per-username bucket here; see below). + // per-username bucket here; see below). Wider than AUTH_FAILURE_MAX: it refuses + // even a correct password, so behind a shared egress IP (NAT, a tunnel) a narrow + // cap would let a few wrong guesses lock everyone out. const ipFail = authFailures.get(clientIp) ?? 0; if (ipFail >= AUTH_IP_FAILURE_MAX) { sendAuthRateLimit(reply, authFailures, clientIp); diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index 2bf91914..712bd5f5 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -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 { } async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise { - 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'); diff --git a/test/multiuser-auth.test.ts b/test/multiuser-auth.test.ts index fd9c7204..fc9e765e 100644 --- a/test/multiuser-auth.test.ts +++ b/test/multiuser-auth.test.ts @@ -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 = {}; @@ -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)', () => {