@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 +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/oauth-handlers.test.ts +122 -1
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-users.ts +3 -0
- package/src/hub-db.ts +85 -1
- package/src/invites.ts +155 -31
- package/src/oauth-handlers.ts +35 -1
- package/src/rate-limit.ts +111 -20
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|