@openparachute/hub 0.7.3-rc.4 → 0.7.3-rc.6
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__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/oauth-handlers.test.ts +122 -1
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/admin-handlers.ts +53 -16
- package/src/api-vault-caps.ts +206 -0
- package/src/hub-server.ts +27 -0
- package/src/oauth-handlers.ts +35 -1
- package/src/rate-limit.ts +73 -19
- package/web/ui/dist/assets/{index-B5AUE359.js → index-CKv8qCCQ.js} +13 -13
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/api/vault-caps*` (B5 admin visibility / D-slice).
|
|
3
|
+
* Covers:
|
|
4
|
+
*
|
|
5
|
+
* - Auth boundary: GET + PUT require a bearer carrying `parachute:host:admin`.
|
|
6
|
+
* - GET joins services.json vault names with persisted caps (uncapped =
|
|
7
|
+
* null cap_bytes), ordered by name.
|
|
8
|
+
* - PUT sets/updates a cap (upsert), validates positive cap, refuses a
|
|
9
|
+
* vault not registered in services.json (400 vault_not_found).
|
|
10
|
+
* - 405 on wrong methods.
|
|
11
|
+
*/
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { handleListVaultCaps, handleSetVaultCap } from "../api-vault-caps.ts";
|
|
18
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
19
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
20
|
+
import { createUser } from "../users.ts";
|
|
21
|
+
import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
|
|
22
|
+
|
|
23
|
+
const ISSUER = "https://hub.test";
|
|
24
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
db: Database;
|
|
28
|
+
manifestPath: string;
|
|
29
|
+
cleanup: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function manifestWithVaults(...names: string[]): string {
|
|
33
|
+
const paths = names.map((n) => `/vault/${n}`);
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
services: [
|
|
36
|
+
{ name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.0.0-test" },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeHarness(servicesJson?: string): Harness {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-vault-caps-"));
|
|
43
|
+
const db = openHubDb(hubDbPath(dir));
|
|
44
|
+
const manifestPath = join(dir, "services.json");
|
|
45
|
+
writeFileSync(manifestPath, servicesJson ?? manifestWithVaults("beta", "personal"));
|
|
46
|
+
return {
|
|
47
|
+
db,
|
|
48
|
+
manifestPath,
|
|
49
|
+
cleanup: () => {
|
|
50
|
+
db.close();
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let harness: Harness;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
harness = makeHarness();
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
harness.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
|
|
65
|
+
const user = await createUser(harness.db, "operator", "any-password", {
|
|
66
|
+
allowMulti: true,
|
|
67
|
+
passwordChanged: true,
|
|
68
|
+
});
|
|
69
|
+
const minted = await signAccessToken(harness.db, {
|
|
70
|
+
sub: user.id,
|
|
71
|
+
scopes,
|
|
72
|
+
audience: "hub",
|
|
73
|
+
clientId: "parachute-hub-spa",
|
|
74
|
+
issuer: ISSUER,
|
|
75
|
+
ttlSeconds: 600,
|
|
76
|
+
});
|
|
77
|
+
return minted.token;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function req(path: string, init: RequestInit = {}): Request {
|
|
81
|
+
return new Request(`${ISSUER}${path}`, init);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
85
|
+
const headers = new Headers(init.headers ?? {});
|
|
86
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
87
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deps() {
|
|
91
|
+
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("handleListVaultCaps", () => {
|
|
95
|
+
test("401 with no Authorization header", async () => {
|
|
96
|
+
const res = await handleListVaultCaps(req("/api/vault-caps"), deps());
|
|
97
|
+
expect(res.status).toBe(401);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
101
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
102
|
+
const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
|
|
103
|
+
expect(res.status).toBe(403);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("405 on POST", async () => {
|
|
107
|
+
const bearer = await makeAdminBearer();
|
|
108
|
+
const res = await handleListVaultCaps(
|
|
109
|
+
withBearer("/api/vault-caps", bearer, { method: "POST" }),
|
|
110
|
+
deps(),
|
|
111
|
+
);
|
|
112
|
+
expect(res.status).toBe(405);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("joins services.json vaults with caps, uncapped = null cap_bytes", async () => {
|
|
116
|
+
const bearer = await makeAdminBearer();
|
|
117
|
+
// Only "beta" has a persisted cap; "personal" stays uncapped.
|
|
118
|
+
setVaultCap(harness.db, "beta", 1024 * 1024 * 1024);
|
|
119
|
+
const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
122
|
+
const body = (await res.json()) as {
|
|
123
|
+
vault_caps: Array<{ vault_name: string; cap_bytes: number | null }>;
|
|
124
|
+
};
|
|
125
|
+
// Both vaults present, ordered by name (beta, personal).
|
|
126
|
+
expect(body.vault_caps.map((c) => c.vault_name)).toEqual(["beta", "personal"]);
|
|
127
|
+
const beta = body.vault_caps.find((c) => c.vault_name === "beta");
|
|
128
|
+
const personal = body.vault_caps.find((c) => c.vault_name === "personal");
|
|
129
|
+
expect(beta?.cap_bytes).toBe(1024 * 1024 * 1024);
|
|
130
|
+
expect(personal?.cap_bytes).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("handleSetVaultCap", () => {
|
|
135
|
+
test("401 with no Authorization header", async () => {
|
|
136
|
+
const res = await handleSetVaultCap(
|
|
137
|
+
req("/api/vault-caps/beta", {
|
|
138
|
+
method: "PUT",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
141
|
+
}),
|
|
142
|
+
"beta",
|
|
143
|
+
deps(),
|
|
144
|
+
);
|
|
145
|
+
expect(res.status).toBe(401);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
149
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
150
|
+
const res = await handleSetVaultCap(
|
|
151
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
headers: { "content-type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
155
|
+
}),
|
|
156
|
+
"beta",
|
|
157
|
+
deps(),
|
|
158
|
+
);
|
|
159
|
+
expect(res.status).toBe(403);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("400 on a fractional (non-integer) cap", async () => {
|
|
163
|
+
const bearer = await makeAdminBearer();
|
|
164
|
+
const res = await handleSetVaultCap(
|
|
165
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
166
|
+
method: "PUT",
|
|
167
|
+
headers: { "content-type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ cap_bytes: 1.5 }),
|
|
169
|
+
}),
|
|
170
|
+
"beta",
|
|
171
|
+
deps(),
|
|
172
|
+
);
|
|
173
|
+
expect(res.status).toBe(400);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("405 on GET", async () => {
|
|
177
|
+
const bearer = await makeAdminBearer();
|
|
178
|
+
const res = await handleSetVaultCap(withBearer("/api/vault-caps/beta", bearer), "beta", deps());
|
|
179
|
+
expect(res.status).toBe(405);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("sets a cap on a registered vault and persists it (upsert)", async () => {
|
|
183
|
+
const bearer = await makeAdminBearer();
|
|
184
|
+
const res = await handleSetVaultCap(
|
|
185
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ cap_bytes: 2 * 1024 * 1024 * 1024 }),
|
|
189
|
+
}),
|
|
190
|
+
"beta",
|
|
191
|
+
deps(),
|
|
192
|
+
);
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
const body = (await res.json()) as { vault_cap: { cap_bytes: number } };
|
|
195
|
+
expect(body.vault_cap.cap_bytes).toBe(2 * 1024 * 1024 * 1024);
|
|
196
|
+
expect(getVaultCapBytes(harness.db, "beta")).toBe(2 * 1024 * 1024 * 1024);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("400 vault_not_found for a vault not in services.json", async () => {
|
|
200
|
+
const bearer = await makeAdminBearer();
|
|
201
|
+
const res = await handleSetVaultCap(
|
|
202
|
+
withBearer("/api/vault-caps/ghost", bearer, {
|
|
203
|
+
method: "PUT",
|
|
204
|
+
headers: { "content-type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
206
|
+
}),
|
|
207
|
+
"ghost",
|
|
208
|
+
deps(),
|
|
209
|
+
);
|
|
210
|
+
expect(res.status).toBe(400);
|
|
211
|
+
const body = (await res.json()) as { error: string };
|
|
212
|
+
expect(body.error).toBe("vault_not_found");
|
|
213
|
+
// Nothing persisted for the rejected name.
|
|
214
|
+
expect(getVaultCapBytes(harness.db, "ghost")).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("400 on non-positive cap", async () => {
|
|
218
|
+
const bearer = await makeAdminBearer();
|
|
219
|
+
const res = await handleSetVaultCap(
|
|
220
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
221
|
+
method: "PUT",
|
|
222
|
+
headers: { "content-type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ cap_bytes: 0 }),
|
|
224
|
+
}),
|
|
225
|
+
"beta",
|
|
226
|
+
deps(),
|
|
227
|
+
);
|
|
228
|
+
expect(res.status).toBe(400);
|
|
229
|
+
const body = (await res.json()) as { error: string };
|
|
230
|
+
expect(body.error).toBe("invalid_request");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -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", () => {
|