@openparachute/hub 0.7.3-rc.3 → 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.3",
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": {
@@ -733,6 +733,7 @@ import { findGrant, recordGrant } from "../grants.ts";
733
733
  import { findInviteByHash, issueInvite } from "../invites.ts";
734
734
  import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
735
735
  import { createUser, setUserVaults } from "../users.ts";
736
+ import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
736
737
 
737
738
  const VAULT_ORIGIN = "http://127.0.0.1:19400";
738
739
  const AGENT_ORIGIN = "http://127.0.0.1:19410";
@@ -1026,11 +1027,19 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1026
1027
  const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1027
1028
  const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
1028
1029
  const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1029
- db.prepare("UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ?").run(
1030
- new Date().toISOString(),
1031
- alice.id,
1032
- redeemedWork.invite.tokenHash,
1033
- );
1030
+ // Simulate a fully-redeemed single-use invite exactly as the redeem path
1031
+ // leaves it (v15): used_at stamped AND used_count bumped to max_uses, so
1032
+ // the cascade's `used_count < max_uses` exhaustion guard treats it as
1033
+ // terminal (untouched), same as the pre-v15 `used_at IS NULL` guard did.
1034
+ db.prepare(
1035
+ "UPDATE invites SET used_at = ?, used_count = 1, redeemed_user_id = ? WHERE token = ?",
1036
+ ).run(new Date().toISOString(), alice.id, redeemedWork.invite.tokenHash);
1037
+
1038
+ // 4b. vault_caps (v15): one row per vault. The work row is dropped by
1039
+ // the cascade; the default row stays (a re-created same-name vault
1040
+ // must not inherit a stale cap).
1041
+ setVaultCap(db, "work", 1024 * 1024 * 1024);
1042
+ setVaultCap(db, "default", 2 * 1024 * 1024 * 1024);
1034
1043
 
1035
1044
  // 5. Connections: one sourced on work (torn down), one on default (kept).
1036
1045
  putConnection(store, {
@@ -1092,6 +1101,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1092
1101
  grants_dropped: number;
1093
1102
  user_vaults_removed: number;
1094
1103
  invites_invalidated: number;
1104
+ vault_cap_removed: boolean;
1095
1105
  connections_torn_down: number;
1096
1106
  orphaned_channels: string[];
1097
1107
  vault_removed: boolean;
@@ -1134,6 +1144,11 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1134
1144
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
1135
1145
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
1136
1146
 
1147
+ // 4b. vault_caps: the work cap row dropped; the default cap row stays.
1148
+ expect(out.cascade.vault_cap_removed).toBe(true);
1149
+ expect(getVaultCapBytes(db, "work")).toBeNull();
1150
+ expect(getVaultCapBytes(db, "default")).toBe(2 * 1024 * 1024 * 1024);
1151
+
1137
1152
  // 5. Connections: work connection torn down (trigger deregistered +
1138
1153
  // channel entry deleted + record removed); default connection kept.
1139
1154
  expect(out.cascade.connections_torn_down).toBe(1);
@@ -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