@openparachute/hub 0.7.3-rc.4 → 0.7.3-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.4",
3
+ "version": "0.7.3-rc.5",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { handleAdminLoginTotpPost } from "../admin-handlers.ts";
6
+ import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
7
7
  import { approveClient, getClient, registerClient } from "../clients.ts";
8
8
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
9
9
  import { findGrant, recordGrant } from "../grants.ts";
@@ -1391,6 +1391,7 @@ describe("handleAuthorizePost — vault picker", () => {
1391
1391
 
1392
1392
  describe("handleAuthorizePost — login submit", () => {
1393
1393
  test("sets session cookie and redirects to GET on valid credentials", async () => {
1394
+ resetRateLimit();
1394
1395
  const { db, cleanup } = await makeDb();
1395
1396
  try {
1396
1397
  await createUser(db, "owner", "hunter2");
@@ -1428,6 +1429,7 @@ describe("handleAuthorizePost — login submit", () => {
1428
1429
  });
1429
1430
 
1430
1431
  test("rejects bad password with 401, no cookie", async () => {
1432
+ resetRateLimit();
1431
1433
  const { db, cleanup } = await makeDb();
1432
1434
  try {
1433
1435
  await createUser(db, "owner", "hunter2");
@@ -1461,6 +1463,125 @@ describe("handleAuthorizePost — login submit", () => {
1461
1463
  });
1462
1464
  });
1463
1465
 
1466
+ // --- Shared per-account login bucket across BOTH password doors -------------
1467
+ //
1468
+ // The shared-egress-IP fix re-keyed the login floor to (ip,username) AND wired
1469
+ // the same `loginRateLimiter` instance into the previously-ungated
1470
+ // `/oauth/authorize` password door. Both doors must share ONE per-account
1471
+ // bucket, so an attacker can't get 5 tries at `/login` PLUS another 5 at
1472
+ // `/oauth/authorize` for the same (ip,username).
1473
+ describe("login floor is shared across /login and /oauth/authorize (same per-account bucket)", () => {
1474
+ const ATTACK_IP = "203.0.113.200";
1475
+
1476
+ function adminLoginReq(username: string, password: string): Request {
1477
+ const body = new URLSearchParams({
1478
+ __csrf: TEST_CSRF,
1479
+ username,
1480
+ password,
1481
+ next: "/admin/vaults",
1482
+ });
1483
+ return new Request("http://hub.test/login", {
1484
+ method: "POST",
1485
+ headers: {
1486
+ "content-type": "application/x-www-form-urlencoded",
1487
+ cookie: CSRF_COOKIE,
1488
+ "cf-connecting-ip": ATTACK_IP,
1489
+ },
1490
+ body,
1491
+ });
1492
+ }
1493
+
1494
+ function oauthLoginReq(
1495
+ username: string,
1496
+ password: string,
1497
+ clientId: string,
1498
+ challenge: string,
1499
+ ): Request {
1500
+ const body = new URLSearchParams({
1501
+ __action: "login",
1502
+ __csrf: TEST_CSRF,
1503
+ username,
1504
+ password,
1505
+ client_id: clientId,
1506
+ redirect_uri: "https://app.example/cb",
1507
+ response_type: "code",
1508
+ scope: "vault:read",
1509
+ code_challenge: challenge,
1510
+ code_challenge_method: "S256",
1511
+ });
1512
+ return new Request(`${ISSUER}/oauth/authorize`, {
1513
+ method: "POST",
1514
+ headers: {
1515
+ "content-type": "application/x-www-form-urlencoded",
1516
+ cookie: CSRF_COOKIE,
1517
+ "cf-connecting-ip": ATTACK_IP,
1518
+ },
1519
+ body,
1520
+ });
1521
+ }
1522
+
1523
+ // (e) 5 at /login + 1 at /oauth/authorize for the same (ip,username) → the
1524
+ // 6th (the /oauth/authorize attempt) is denied regardless of which door it
1525
+ // came through.
1526
+ test("(e) 5 /login attempts then 1 /oauth/authorize attempt → the 6th door is 429", async () => {
1527
+ const { db, cleanup } = await makeDb();
1528
+ resetRateLimit();
1529
+ try {
1530
+ await createUser(db, "owner", "hunter2", { passwordChanged: true });
1531
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1532
+ const { challenge } = makePkce();
1533
+
1534
+ // Burn the full 5-slot floor at the /login door (wrong password → 401).
1535
+ for (let i = 0; i < 5; i++) {
1536
+ const r = await handleAdminLoginPost(db, adminLoginReq("owner", "wrong"));
1537
+ expect(r.status).toBe(401);
1538
+ }
1539
+ // 6th attempt — at the OTHER door (/oauth/authorize). Must be denied
1540
+ // because both doors share ONE (ip,username) bucket.
1541
+ const denied = await handleAuthorizePost(
1542
+ db,
1543
+ oauthLoginReq("owner", "hunter2", reg.client.clientId, challenge),
1544
+ { issuer: ISSUER },
1545
+ );
1546
+ expect(denied.status).toBe(429);
1547
+ expect(denied.headers.get("retry-after")).not.toBeNull();
1548
+ // No session minted even though the password was correct — the floor
1549
+ // fired before the credential check.
1550
+ expect(cookieValueFrom(denied, "parachute_hub_session")).toBeNull();
1551
+ } finally {
1552
+ cleanup();
1553
+ }
1554
+ });
1555
+
1556
+ test("the shared bucket is per-account: a DIFFERENT username at /oauth/authorize is unaffected", async () => {
1557
+ const { db, cleanup } = await makeDb();
1558
+ resetRateLimit();
1559
+ try {
1560
+ await createUser(db, "alice", "alice-pw", { passwordChanged: true });
1561
+ await createUser(db, "bob", "bob-pw", { passwordChanged: true, allowMulti: true });
1562
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1563
+ const { challenge } = makePkce();
1564
+
1565
+ // Exhaust alice's floor at /login.
1566
+ for (let i = 0; i < 5; i++) {
1567
+ await handleAdminLoginPost(db, adminLoginReq("alice", "wrong"));
1568
+ }
1569
+ expect((await handleAdminLoginPost(db, adminLoginReq("alice", "wrong"))).status).toBe(429);
1570
+
1571
+ // bob (same IP, different username) still signs in at /oauth/authorize.
1572
+ const bobRes = await handleAuthorizePost(
1573
+ db,
1574
+ oauthLoginReq("bob", "bob-pw", reg.client.clientId, challenge),
1575
+ { issuer: ISSUER },
1576
+ );
1577
+ expect(bobRes.status).toBe(302);
1578
+ expect(bobRes.headers.get("location")).toContain("/oauth/authorize?");
1579
+ } finally {
1580
+ cleanup();
1581
+ }
1582
+ });
1583
+ });
1584
+
1464
1585
  // --- OAuth-path TOTP gate (hub#473 P0 bypass regression) -------------------
1465
1586
  //
1466
1587
  // The OAuth login POST (`__action=login`) is the more-common sign-in path
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import {
3
+ AUTH_IP_CEILING_MAX_ATTEMPTS,
3
4
  CHANGE_PASSWORD_MAX_ATTEMPTS,
4
5
  CHANGE_PASSWORD_WINDOW_MS,
5
6
  MAX_ATTEMPTS,
@@ -7,9 +8,12 @@ import {
7
8
  UNKNOWN_IP_SENTINEL,
8
9
  WINDOW_MS,
9
10
  __resetForTests,
11
+ authIpCeilingRateLimiter,
10
12
  changePasswordRateLimiter,
11
13
  checkAndRecord,
12
14
  clientIpFromRequest,
15
+ compositeKey,
16
+ loginRateLimiter,
13
17
  } from "../rate-limit.ts";
14
18
 
15
19
  afterEach(() => {
@@ -302,3 +306,85 @@ describe("changePasswordRateLimiter — tighter floor for /account/change-passwo
302
306
  expect(changePasswordRateLimiter.checkAndRecord("user-a", now).allowed).toBe(true);
303
307
  });
304
308
  });
309
+
310
+ // The shared-egress-IP fix: the per-account FLOOR is now keyed by
311
+ // `compositeKey(ip, identity)` so a room of users behind ONE NAT'd /
312
+ // Cloudflare egress IP doesn't pool into one 5-slot bucket. A coarse per-IP
313
+ // CEILING (60/15min) backstops username-rotation across the floors.
314
+ describe("compositeKey + shared-egress-IP login floor", () => {
315
+ test("normalizes identity: trims + lowercases so casing/whitespace share a bucket", () => {
316
+ expect(compositeKey("1.2.3.4", "Alice")).toBe("1.2.3.4|alice");
317
+ expect(compositeKey("1.2.3.4", " ALICE ")).toBe("1.2.3.4|alice");
318
+ // Case-flip evasion is closed: 'Alice' and 'alice' resolve to one key.
319
+ expect(compositeKey("1.2.3.4", "Alice")).toBe(compositeKey("1.2.3.4", "alice"));
320
+ });
321
+
322
+ // (a) REGRESSION: two distinct usernames from the SAME ip each get a full
323
+ // independent 5/15min floor (the shared-wifi bug). Before the fix both would
324
+ // have pooled into one per-IP bucket and the second user's 1st attempt would
325
+ // have been the 6th overall → 429.
326
+ test("(a) two usernames from the same IP each get an independent 5/15min floor", () => {
327
+ const ip = "203.0.113.7";
328
+ const now = new Date("2026-06-25T12:00:00Z");
329
+ // Alice exhausts her floor.
330
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
331
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
332
+ }
333
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(false);
334
+ // Bob (same IP, different username) still has a fresh full floor.
335
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
336
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(true);
337
+ }
338
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(false);
339
+ });
340
+
341
+ // (b) a single (ip,username) still denies on the 6th attempt.
342
+ test("(b) a single (ip,username) still denies on the 6th attempt", () => {
343
+ const ip = "203.0.113.7";
344
+ const now = new Date("2026-06-25T12:00:00Z");
345
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
346
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
347
+ }
348
+ const denied = loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now);
349
+ expect(denied.allowed).toBe(false);
350
+ expect(denied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
351
+ });
352
+
353
+ // (c) the per-IP ceiling denies on the 61st attempt from one IP even across
354
+ // rotated usernames (each username's own floor never trips because each only
355
+ // sees one attempt, but the coarse ceiling caps total per-IP volume).
356
+ test("(c) per-IP ceiling denies the 61st attempt across rotated usernames", () => {
357
+ const ip = "203.0.113.7";
358
+ const now = new Date("2026-06-25T12:00:00Z");
359
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
360
+ // Rotate a fresh username every attempt so no per-account floor ever fills.
361
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, `u${i}`), now).allowed).toBe(true);
362
+ expect(authIpCeilingRateLimiter.checkAndRecord(ip, now).allowed).toBe(true);
363
+ }
364
+ // 61st attempt: a brand-new username (floor is fresh) but the ceiling is full.
365
+ expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "u60"), now).allowed).toBe(true);
366
+ const ceilingDenied = authIpCeilingRateLimiter.checkAndRecord(ip, now);
367
+ expect(ceilingDenied.allowed).toBe(false);
368
+ expect(ceilingDenied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
369
+ });
370
+
371
+ test("the ceiling is per-IP: a different IP is unaffected by another's full ceiling", () => {
372
+ const now = new Date("2026-06-25T12:00:00Z");
373
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
374
+ authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
375
+ }
376
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
377
+ expect(authIpCeilingRateLimiter.checkAndRecord("198.51.100.99", now).allowed).toBe(true);
378
+ });
379
+
380
+ test("ceiling matches the signup precedent (60) and `__resetForTests` clears it", () => {
381
+ expect(AUTH_IP_CEILING_MAX_ATTEMPTS).toBe(60);
382
+ const now = new Date("2026-06-25T12:00:00Z");
383
+ for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
384
+ authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
385
+ }
386
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
387
+ __resetForTests();
388
+ expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(true);
389
+ });
390
+ });
@@ -332,6 +332,100 @@ describe("login two-step (TOTP) — hub#473", () => {
332
332
  expect(denied.status).toBe(429);
333
333
  expect(denied.headers.get("retry-after")).not.toBeNull();
334
334
  });
335
+
336
+ // Fallback path: when the pending login can't be resolved (bad/expired
337
+ // token), the floor keys by IP ALONE — NOT the (rotating) pendingToken.
338
+ // Proven here by sending a DIFFERENT bogus pending cookie on every attempt
339
+ // from one IP: if the floor keyed by the token each would get a fresh bucket
340
+ // and never 429; keying by IP, the 6th still trips.
341
+ test("2FA floor with unresolvable pending tokens keys by IP, not the token", async () => {
342
+ const buildReq = (bogusToken: string) => {
343
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
344
+ return new Request("http://hub.test/login/2fa", {
345
+ method: "POST",
346
+ headers: {
347
+ ...tf.headers,
348
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${bogusToken}`,
349
+ "cf-connecting-ip": "203.0.113.77",
350
+ },
351
+ body: tf.body,
352
+ });
353
+ };
354
+ for (let i = 0; i < 5; i++) {
355
+ // A unique bogus token per attempt → never resolves to a pending login.
356
+ const r = await handleAdminLoginTotpPost(harness.db, buildReq(`bogus-${i}`));
357
+ expect(r.status).toBe(401); // no pending login → 401, counts toward the IP floor
358
+ }
359
+ const denied = await handleAdminLoginTotpPost(harness.db, buildReq("bogus-final"));
360
+ expect(denied.status).toBe(429);
361
+ expect(denied.headers.get("retry-after")).not.toBeNull();
362
+ });
363
+
364
+ // (d) The 2FA floor is keyed by (ip,userId), NOT the rotating pendingToken.
365
+ // Re-POSTing /login mints a FRESH pendingToken for the SAME user — that must
366
+ // NOT reset the per-account TOTP floor (otherwise an attacker grinds TOTP
367
+ // forever by re-doing the password step between every 5 code attempts).
368
+ test("(d) re-POSTing /login (new pendingToken) does NOT reset the per-account TOTP floor", async () => {
369
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
370
+ passwordChanged: true,
371
+ });
372
+ await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
373
+ const ATTACK_IP = "203.0.113.66";
374
+
375
+ const passwordPost = async (): Promise<string> => {
376
+ const pw = formBody({
377
+ [CSRF_FIELD_NAME]: TEST_CSRF,
378
+ username: "owner",
379
+ password: "owner-password-123",
380
+ next: "/admin/vaults",
381
+ });
382
+ const res = await handleAdminLoginPost(
383
+ harness.db,
384
+ new Request("http://hub.test/login", {
385
+ method: "POST",
386
+ headers: { ...pw.headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ATTACK_IP },
387
+ body: pw.body,
388
+ }),
389
+ );
390
+ const token = cookieFrom(res, PENDING_LOGIN_COOKIE_NAME);
391
+ expect(token).toBeTruthy();
392
+ return token!;
393
+ };
394
+
395
+ const wrongTotp = (pendingToken: string): Request => {
396
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
397
+ return new Request("http://hub.test/login/2fa", {
398
+ method: "POST",
399
+ headers: {
400
+ ...tf.headers,
401
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
402
+ "cf-connecting-ip": ATTACK_IP,
403
+ },
404
+ body: tf.body,
405
+ });
406
+ };
407
+
408
+ // First password step → pendingToken1.
409
+ const token1 = await passwordPost();
410
+ // 4 wrong TOTP attempts against pendingToken1 (all 401, all count toward
411
+ // the (ip,userId) bucket).
412
+ for (let i = 0; i < 4; i++) {
413
+ expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token1))).status).toBe(401);
414
+ }
415
+
416
+ // Re-do the password step → a BRAND-NEW pendingToken2 (same userId).
417
+ const token2 = await passwordPost();
418
+ expect(token2).not.toBe(token1);
419
+
420
+ // 5th wrong TOTP — under pendingToken2 but the SAME (ip,userId) bucket →
421
+ // still the 5th attempt → allowed (401), not reset to fresh.
422
+ expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token2))).status).toBe(401);
423
+ // 6th wrong TOTP → floor full → 429. If the floor were keyed by the
424
+ // pendingToken, token2 would have a fresh bucket and this would be a 401.
425
+ const denied = await handleAdminLoginTotpPost(harness.db, wrongTotp(token2));
426
+ expect(denied.status).toBe(429);
427
+ expect(denied.headers.get("retry-after")).not.toBeNull();
428
+ });
335
429
  });
336
430
 
337
431
  describe("/account/2fa handlers — hub#473", () => {
@@ -21,7 +21,13 @@ import {
21
21
  getPendingLogin,
22
22
  parsePendingLoginCookie,
23
23
  } from "./pending-login.ts";
24
- import { checkAndRecord, clientIpFromRequest, totpRateLimiter } from "./rate-limit.ts";
24
+ import {
25
+ authIpCeilingRateLimiter,
26
+ clientIpFromRequest,
27
+ compositeKey,
28
+ loginRateLimiter,
29
+ totpRateLimiter,
30
+ } from "./rate-limit.ts";
25
31
  import { isHttpsRequest } from "./request-protocol.ts";
26
32
  import {
27
33
  SESSION_TTL_MS,
@@ -167,24 +173,42 @@ export async function handleAdminLoginPost(
167
173
  );
168
174
  }
169
175
  // Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
170
- // burn a bucket slot for the victim's IP) but *before* credential check.
176
+ // burn a bucket slot for the victim) but *before* credential check.
171
177
  // Every legitimate login attempt — wrong password, missing user, eventually
172
- // failed-2FA (#186) — counts toward the same bucket so an attacker can't
173
- // partition the cooldown across stages.
178
+ // failed-2FA — counts toward the same bucket so an attacker can't partition
179
+ // the cooldown across stages.
180
+ //
181
+ // Two tiers (the shared-egress-IP fix): a coarse per-IP CEILING (60/15min)
182
+ // backstops username-rotation, then a per-(ip,username) FLOOR (5/15min) so
183
+ // each account behind a shared NAT / Cloudflare egress IP gets its own
184
+ // bucket instead of the whole room pooling into one 5-slot per-IP bucket.
185
+ // `username` is hoisted above the gate because the floor keys on it. The same
186
+ // `loginRateLimiter` + `compositeKey(ip, username)` scheme backs the
187
+ // `/oauth/authorize` password door, so both doors share ONE per-account
188
+ // bucket.
189
+ const username = String(form.get("username") ?? "");
174
190
  const clientIp = clientIpFromRequest(req);
175
191
  const now = deps.now ? deps.now() : new Date();
176
- const gate = checkAndRecord(clientIp, now);
177
- if (!gate.allowed) {
192
+ // Both checks always run (no short-circuit): a denied `checkAndRecord` does
193
+ // NOT append a timestamp, so running the floor after a denied ceiling never
194
+ // double-counts. We need both recorded so each independently tracks its own
195
+ // bucket (the floor still gates per-account even when the ceiling is fine).
196
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
197
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
198
+ if (!ceiling.allowed || !floor.allowed) {
199
+ const retryAfterSeconds = Math.max(
200
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
201
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
202
+ );
178
203
  return htmlResponse(
179
204
  renderAdminError({
180
205
  title: "Too many login attempts",
181
- message: `Too many login attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
206
+ message: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
182
207
  }),
183
208
  429,
184
- { "retry-after": String(gate.retryAfterSeconds ?? 1) },
209
+ { "retry-after": String(retryAfterSeconds) },
185
210
  );
186
211
  }
187
- const username = String(form.get("username") ?? "");
188
212
  const password = String(form.get("password") ?? "");
189
213
  const next = safeNext(String(form.get("next") ?? ""));
190
214
  const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
@@ -314,23 +338,36 @@ export async function handleAdminLoginTotpPost(
314
338
  const next = safeNext(String(form.get("next") ?? ""));
315
339
  const code = String(form.get("code") ?? "");
316
340
 
317
- // Rate-limit BEFORE resolving the pending login or verifying the factor.
341
+ // Rate-limit BEFORE verifying the factor. Resolve the pending login FIRST so
342
+ // the per-account floor can key on the STABLE userId (NOT the pendingToken,
343
+ // which a fresh /login success rotates and would reset the bucket). Resolving
344
+ // here also lets a bad/expired pending token fall back to keying the floor by
345
+ // IP alone — never by the rotating pendingToken.
318
346
  const clientIp = clientIpFromRequest(req);
319
347
  const now = deps.now ? deps.now() : new Date();
320
- const gate = totpRateLimiter.checkAndRecord(clientIp, now);
321
- if (!gate.allowed) {
348
+ const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
349
+ const pending = getPendingLogin(pendingToken, () => now);
350
+ // Two tiers (the shared-egress-IP fix): coarse per-IP CEILING (60/15min) +
351
+ // per-(ip,userId) FLOOR (5/15min). When userId can't be resolved (bad/expired
352
+ // pending token), key the floor by IP alone — do NOT key by the pendingToken.
353
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
354
+ const floorKey = pending ? compositeKey(clientIp, pending.userId) : clientIp;
355
+ const floor = totpRateLimiter.checkAndRecord(floorKey, now);
356
+ if (!ceiling.allowed || !floor.allowed) {
357
+ const retryAfterSeconds = Math.max(
358
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
359
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
360
+ );
322
361
  return htmlResponse(
323
362
  renderAdminError({
324
363
  title: "Too many attempts",
325
- message: `Too many verification attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
364
+ message: `Too many verification attempts. Try again in ${retryAfterSeconds} seconds.`,
326
365
  }),
327
366
  429,
328
- { "retry-after": String(gate.retryAfterSeconds ?? 1) },
367
+ { "retry-after": String(retryAfterSeconds) },
329
368
  );
330
369
  }
331
370
 
332
- const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
333
- const pending = getPendingLogin(pendingToken, () => now);
334
371
  if (!pending) {
335
372
  // No live pending login — expired, missing, or tampered. Send the user
336
373
  // back to the start; clear any stale pending cookie.
@@ -72,6 +72,12 @@ import {
72
72
  } from "./oauth-ui.ts";
73
73
  import { isSameOriginRequest } from "./origin-check.ts";
74
74
  import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
75
+ import {
76
+ authIpCeilingRateLimiter,
77
+ clientIpFromRequest,
78
+ compositeKey,
79
+ loginRateLimiter,
80
+ } from "./rate-limit.ts";
75
81
  import { isHttpsRequest } from "./request-protocol.ts";
76
82
  import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
77
83
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
@@ -1386,12 +1392,40 @@ async function handleLoginSubmit(
1386
1392
  db: Database,
1387
1393
  req: Request,
1388
1394
  form: Awaited<ReturnType<Request["formData"]>>,
1389
- _deps: OAuthDeps,
1395
+ deps: OAuthDeps,
1390
1396
  csrfToken: string,
1391
1397
  ): Promise<Response> {
1392
1398
  const username = String(form.get("username") ?? "");
1393
1399
  const password = String(form.get("password") ?? "");
1394
1400
  const params = paramsFromForm(form);
1401
+ // Rate-limit gate — AFTER CSRF (verified by the `handleAuthorizePost` caller)
1402
+ // and BEFORE the credential check. This `/oauth/authorize` password door had
1403
+ // NO rate-limit before; without it an attacker got an unbounded brute-force
1404
+ // channel that bypassed `/login`'s floor entirely. Two tiers, reusing the
1405
+ // SAME `loginRateLimiter` instance + the SAME `compositeKey(ip, username)`
1406
+ // scheme as `/login`, so the two password doors share ONE per-account bucket
1407
+ // (an attacker can't get 5 tries at `/login` PLUS another 5 here). The coarse
1408
+ // per-IP CEILING backstops username-rotation; the per-(ip,username) FLOOR is
1409
+ // the brute-force floor.
1410
+ const clientIp = clientIpFromRequest(req);
1411
+ const now = deps.now ? deps.now() : new Date();
1412
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
1413
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
1414
+ if (!ceiling.allowed || !floor.allowed) {
1415
+ const retryAfterSeconds = Math.max(
1416
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
1417
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
1418
+ );
1419
+ return htmlResponse(
1420
+ renderLogin({
1421
+ params,
1422
+ csrfToken,
1423
+ errorMessage: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
1424
+ }),
1425
+ 429,
1426
+ { "retry-after": String(retryAfterSeconds) },
1427
+ );
1428
+ }
1395
1429
  if (!username || !password) {
1396
1430
  return htmlResponse(
1397
1431
  renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
package/src/rate-limit.ts CHANGED
@@ -8,15 +8,16 @@
8
8
  * because its endpoint is meant to be redeemed by a room of people sharing one
9
9
  * egress IP (see `SIGNUP_MAX_ATTEMPTS`).
10
10
  *
11
- * - `/login` (per-IP, hub#187 / hub#188): 5 attempts / 15 min.
12
- * Lands as a floor under brute-force after hub#187 collapsed the
13
- * public-reach matrix: with a cloudflare tunnel up, `/login` is now
14
- * reachable from the open internet, and 2FA (#186) is the next PR
15
- * rather than this one. A 5-attempts-per-15-minute bucket per IP is
16
- * the standard login-form floor; it's not the primary defense, just
17
- * the one that turns "infinite credential grinding" into "rotate IPs".
18
- * (Endpoint was `/admin/login` pre-rename; bucket logic is path-
19
- * agnostic so the rename was a comment-only change here.)
11
+ * - `/login` (per-account floor + per-IP ceiling): the FLOOR is keyed by
12
+ * `compositeKey(ip, username)` at 5 attempts / 15 min, shared with the
13
+ * `/oauth/authorize` password door so both doors feed ONE per-account
14
+ * bucket; the CEILING is `authIpCeilingRateLimiter` at 60 / 15 min keyed
15
+ * by IP alone. Re-keyed from pure per-IP so a room of users behind one
16
+ * NAT'd egress IP (shared wifi / cloudflare tunnel) no longer pool into a
17
+ * single 5-slot bucket the per-account floor still turns "infinite
18
+ * credential grinding" into "rotate IPs", while the IP ceiling backstops
19
+ * username-rotation. (Endpoint was `/admin/login` pre-rename; bucket
20
+ * logic is path-agnostic so the rename was a comment-only change here.)
20
21
  *
21
22
  * - `/account/change-password` (per-user, hub#282): 3 attempts / 5 min.
22
23
  * The endpoint is session-gated, so the threat model isn't open-
@@ -91,13 +92,27 @@ export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
91
92
  * factor step (hub#473) sits behind a verified password + a short-lived
92
93
  * pending-login token, so the threat model is "attacker who already has the
93
94
  * password grinding 6-digit codes / backup codes." A 5-attempt / 15-min
94
- * bucket per IP turns 10^6-space TOTP grinding into "rotate IPs," same floor
95
- * as `/login`. Keyed by IP (the pending-login token is short-lived and an
96
- * attacker could mint many, so IP is the stable actor key here).
95
+ * floor turns 10^6-space TOTP grinding into "rotate IPs," same floor as
96
+ * `/login`. Keyed by `compositeKey(ip, userId)` (the shared-egress-IP fix) so
97
+ * each 2FA-enrolled user behind one NAT'd / Cloudflare egress IP gets their own
98
+ * floor — the STABLE userId from the pending-login, NOT the rotating pending
99
+ * token. The coarse per-IP `authIpCeilingRateLimiter` backstops it.
97
100
  */
98
101
  export const TOTP_WINDOW_MS = 15 * 60 * 1000;
99
102
  /** `/login/2fa` attempts allowed per window. 6th within the window is denied. */
100
103
  export const TOTP_MAX_ATTEMPTS = 5;
104
+ /**
105
+ * Coarse per-IP CEILING shared by all interactive auth doors (`/login`, the
106
+ * `/oauth/authorize` password door, `/login/2fa`). 60 attempts / 15 min — the
107
+ * same generous cap as public signup (`SIGNUP_MAX_ATTEMPTS`), chosen so a
108
+ * ~20-person demo room behind ONE NAT'd / Cloudflare egress IP clears it
109
+ * comfortably. It exists ONLY as a backstop against username-rotation: the
110
+ * per-(ip,identity) FLOORs stay tight at 5/15min, but an attacker who rotates
111
+ * usernames against one IP would otherwise get a fresh 5-slot floor per name.
112
+ * This ceiling caps total per-IP auth volume regardless of identity. Reuses the
113
+ * 15-min `WINDOW_MS`.
114
+ */
115
+ export const AUTH_IP_CEILING_MAX_ATTEMPTS = 60;
101
116
  /**
102
117
  * `POST /account/vault-token/<name>` window length: 10 minutes. The endpoint
103
118
  * is session-gated and assignment-capped (a friend can only mint
@@ -235,9 +250,19 @@ export class RateLimiter {
235
250
  }
236
251
 
237
252
  /**
238
- * `/login` rate limiter — per-IP, 5 attempts / 15 min. Exported as a
239
- * singleton so all callers share one bucket map (rotating IPs across the
240
- * test suite or across a real attack must hit the same backing store).
253
+ * `/login` (+ the `/oauth/authorize` password door) rate limiter — per-
254
+ * account/per-half-login floor, keyed (ip,identity), 5 attempts / 15 min.
255
+ * RE-KEYED from per-IP to `compositeKey(ip, username)` (the shared-egress-IP
256
+ * fix): behind NAT / Cloudflare every user in a room presents one
257
+ * CF-Connecting-IP, so a per-IP-only key pooled the whole room into one 5-slot
258
+ * bucket and 429'd the 6th legitimate sign-in. Keyed by (ip,username) instead,
259
+ * each account gets its own floor while still pinning the floor to the IP so a
260
+ * single attacker can't grind one account from one box past 5 tries. BOTH
261
+ * password doors (`/login` and `/oauth/authorize` `__action=login`) share this
262
+ * ONE instance + the SAME key scheme, so an attacker can't get 5 tries per door
263
+ * against the same account. Exported as a singleton so all callers share one
264
+ * bucket map. The coarse per-IP `authIpCeilingRateLimiter` backstops
265
+ * username-rotation across this floor.
241
266
  */
242
267
  export const loginRateLimiter = new RateLimiter(MAX_ATTEMPTS, WINDOW_MS);
243
268
 
@@ -252,14 +277,30 @@ export const changePasswordRateLimiter = new RateLimiter(
252
277
  );
253
278
 
254
279
  /**
255
- * `/login/2fa` rate limiter — per-IP, 5 attempts / 15 min (hub#473). Bounds
256
- * second-factor grinding by an attacker who already has the password. Separate
280
+ * `/login/2fa` rate limiter — per-account/per-half-login floor, keyed
281
+ * (ip,identity), 5 attempts / 15 min (hub#473). Bounds second-factor grinding
282
+ * by an attacker who already has the password. RE-KEYED from per-IP to
283
+ * `compositeKey(ip, userId)` (the shared-egress-IP fix): keyed by the STABLE
284
+ * userId resolved from the pending-login (NOT the pendingToken, which rotates
285
+ * on every /login success and would reset the bucket). Behind NAT every 2FA-
286
+ * enrolled user in a room would otherwise pool into one per-IP bucket. Separate
257
287
  * bucket from `/login` so a password failure and a TOTP failure don't share a
258
- * window but both are per-IP so rotating egress IPs is the only escape, same
259
- * as the password floor.
288
+ * window. The coarse per-IP `authIpCeilingRateLimiter` backstops this floor.
260
289
  */
261
290
  export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
262
291
 
292
+ /**
293
+ * Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
294
+ * IP ONLY. Shared by all interactive auth doors (`/login`, the
295
+ * `/oauth/authorize` password door, `/login/2fa`) as a backstop against
296
+ * username-rotation: the per-(ip,identity) floors above stay tight, but an
297
+ * attacker rotating usernames against one IP would otherwise get a fresh floor
298
+ * per name. This ceiling caps total per-IP auth volume regardless of identity.
299
+ * Deliberately generous (matches the signup precedent) so a demo room sharing
300
+ * one egress IP clears it.
301
+ */
302
+ export const authIpCeilingRateLimiter = new RateLimiter(AUTH_IP_CEILING_MAX_ATTEMPTS, WINDOW_MS);
303
+
263
304
  /**
264
305
  * `POST /account/vault-token/<name>` rate limiter — per-user, 10 attempts /
265
306
  * 10 min (friend vault-token mint). Keyed by user-id (session-gated endpoint,
@@ -303,6 +344,19 @@ export function __resetForTests(): void {
303
344
  totpRateLimiter.reset();
304
345
  vaultTokenMintRateLimiter.reset();
305
346
  signupRateLimiter.reset();
347
+ authIpCeilingRateLimiter.reset();
348
+ }
349
+
350
+ /**
351
+ * Compose a per-(ip,identity) rate-limit key. Used by the per-account auth
352
+ * FLOORs (`/login` + `/oauth/authorize` keyed by username; `/login/2fa` keyed
353
+ * by userId) so each account behind a shared egress IP gets its own bucket
354
+ * while the floor still pins to the IP. The identity is trimmed + lowercased so
355
+ * `'Alice'` and `'alice'` share one bucket — otherwise an attacker could
356
+ * case-flip a username to mint a fresh 5-slot floor per casing.
357
+ */
358
+ export function compositeKey(ip: string, id: string): string {
359
+ return `${ip}|${id.trim().toLowerCase()}`;
306
360
  }
307
361
 
308
362
  /**