@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/src/admin-handlers.ts
CHANGED
|
@@ -21,7 +21,13 @@ import {
|
|
|
21
21
|
getPendingLogin,
|
|
22
22
|
parsePendingLoginCookie,
|
|
23
23
|
} from "./pending-login.ts";
|
|
24
|
-
import {
|
|
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
|
|
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
|
|
173
|
-
//
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
206
|
+
message: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
|
|
182
207
|
}),
|
|
183
208
|
429,
|
|
184
|
-
{ "retry-after": String(
|
|
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
|
|
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
|
|
321
|
-
|
|
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
|
|
364
|
+
message: `Too many verification attempts. Try again in ${retryAfterSeconds} seconds.`,
|
|
326
365
|
}),
|
|
327
366
|
429,
|
|
328
|
-
{ "retry-after": String(
|
|
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.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/vault-caps*` — admin visibility + edit for per-vault storage caps
|
|
3
|
+
* (DEMO-PREP-2026-06-25 Workstream B5 / D-slice).
|
|
4
|
+
*
|
|
5
|
+
* PR #686 shipped the `vault_caps` table + `setVaultCap`/`getVaultCapBytes`
|
|
6
|
+
* (provision-time persistence) and a separate Phase-2 PR reads + ENFORCES the
|
|
7
|
+
* cap at upload time. This file is the OPERATOR-FACING seam in between: the
|
|
8
|
+
* admin SPA needs to SEE who has what cap and EDIT a cap live in the demo.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces:
|
|
11
|
+
*
|
|
12
|
+
* GET /api/vault-caps list every vault (from services.json) joined
|
|
13
|
+
* with its persisted cap (host:admin)
|
|
14
|
+
* PUT /api/vault-caps/:name set/update a vault's cap to N bytes (host:admin)
|
|
15
|
+
*
|
|
16
|
+
* The list is the JOIN of "what vaults exist" (services.json, the canonical
|
|
17
|
+
* vault-name source) and "what caps are persisted" (`vault_caps`). A vault
|
|
18
|
+
* with no cap row appears with `cap_bytes: null` (uncapped) — the same
|
|
19
|
+
* "uncapped = no row" contract the Phase-2 enforcement reader relies on.
|
|
20
|
+
*
|
|
21
|
+
* Wire shape is snake_case (matches `/api/users`, `/api/invites`). Auth: same
|
|
22
|
+
* `parachute:host:admin` Bearer gate as every other `/api/*` admin surface;
|
|
23
|
+
* the SPA mints it from the session cookie via `/admin/host-admin-token`.
|
|
24
|
+
*
|
|
25
|
+
* Scope discipline: PUT only sets a cap on a vault that is REGISTERED in
|
|
26
|
+
* services.json (rejects a stale / typo name with 400 `vault_not_found`) so an
|
|
27
|
+
* operator can't seed a cap row for a vault that doesn't exist. Additive only —
|
|
28
|
+
* this never changes the provision-time cap-persistence from #686; it's a
|
|
29
|
+
* read + targeted-edit layer on the same table.
|
|
30
|
+
*/
|
|
31
|
+
import type { Database } from "bun:sqlite";
|
|
32
|
+
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
33
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
34
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
35
|
+
import { getVaultCap, setVaultCap } from "./vault-caps.ts";
|
|
36
|
+
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
37
|
+
|
|
38
|
+
export interface ApiVaultCapsDeps {
|
|
39
|
+
db: Database;
|
|
40
|
+
/** Hub origin — JWT `iss` validation. */
|
|
41
|
+
issuer: string;
|
|
42
|
+
/** Override services.json path. Defaults to `~/.parachute/services.json`. */
|
|
43
|
+
manifestPath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One row in the `GET /api/vault-caps` response: a vault name + its cap. A
|
|
48
|
+
* vault with no persisted cap carries `cap_bytes: null` (uncapped); `created_at`
|
|
49
|
+
* / `updated_at` are null too in that case.
|
|
50
|
+
*/
|
|
51
|
+
export interface VaultCapWireShape {
|
|
52
|
+
vault_name: string;
|
|
53
|
+
cap_bytes: number | null;
|
|
54
|
+
created_at: string | null;
|
|
55
|
+
updated_at: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
59
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
60
|
+
status,
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /api/vault-caps — every vault registered in services.json, joined with
|
|
67
|
+
* its persisted cap (null = uncapped). Ordered by vault name.
|
|
68
|
+
*/
|
|
69
|
+
export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps): Promise<Response> {
|
|
70
|
+
if (req.method !== "GET") {
|
|
71
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
77
|
+
}
|
|
78
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
79
|
+
const names = listVaultNamesFromPath(manifestPath);
|
|
80
|
+
const caps: VaultCapWireShape[] = names.map((vaultName) => {
|
|
81
|
+
const cap = getVaultCap(deps.db, vaultName);
|
|
82
|
+
return {
|
|
83
|
+
vault_name: vaultName,
|
|
84
|
+
cap_bytes: cap?.capBytes ?? null,
|
|
85
|
+
created_at: cap?.createdAt ?? null,
|
|
86
|
+
updated_at: cap?.updatedAt ?? null,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
return new Response(JSON.stringify({ vault_caps: caps }), {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface SetCapBody {
|
|
96
|
+
cap_bytes: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ParseErr {
|
|
100
|
+
ok: false;
|
|
101
|
+
status: number;
|
|
102
|
+
error: string;
|
|
103
|
+
description: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function parseSetCapBody(req: Request): Promise<{ ok: true; body: SetCapBody } | ParseErr> {
|
|
107
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
108
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
status: 400,
|
|
112
|
+
error: "invalid_request",
|
|
113
|
+
description: "Content-Type must be application/json",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
let raw: unknown;
|
|
117
|
+
try {
|
|
118
|
+
raw = await req.json();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 400,
|
|
124
|
+
error: "invalid_request",
|
|
125
|
+
description: `invalid JSON body: ${msg}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!raw || typeof raw !== "object") {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 400,
|
|
132
|
+
error: "invalid_request",
|
|
133
|
+
description: "request body must be a JSON object",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const obj = raw as Record<string, unknown>;
|
|
137
|
+
const capBytes = obj.cap_bytes;
|
|
138
|
+
// Positive integer only — the table's CHECK (cap_bytes > 0) is the at-rest
|
|
139
|
+
// backstop; this is the edge validation so the operator gets a clean 400
|
|
140
|
+
// instead of a sqlite constraint error. A fractional byte count (which a byte
|
|
141
|
+
// count can never be) is rejected rather than silently floored downstream.
|
|
142
|
+
if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 400,
|
|
146
|
+
error: "invalid_request",
|
|
147
|
+
description: '"cap_bytes" must be a positive integer number of bytes',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, body: { cap_bytes: capBytes } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* PUT /api/vault-caps/:name — set or update a vault's storage cap.
|
|
155
|
+
*
|
|
156
|
+
* Order of checks (mirrors the /api/users handlers):
|
|
157
|
+
*
|
|
158
|
+
* 1. Method gate (405 on non-PUT).
|
|
159
|
+
* 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
|
|
160
|
+
* 3. Parse + validate body (400 on shape / non-positive cap).
|
|
161
|
+
* 4. Vault is registered in services.json (400 `vault_not_found`) — refuse
|
|
162
|
+
* to seed a cap for a vault that doesn't exist.
|
|
163
|
+
* 5. `setVaultCap` (upsert) — overwrites the cap, bumps `updated_at`,
|
|
164
|
+
* preserves the original `created_at`.
|
|
165
|
+
*
|
|
166
|
+
* Response on success: `200 { vault_cap: <wire shape> }`.
|
|
167
|
+
*/
|
|
168
|
+
export async function handleSetVaultCap(
|
|
169
|
+
req: Request,
|
|
170
|
+
vaultName: string,
|
|
171
|
+
deps: ApiVaultCapsDeps,
|
|
172
|
+
): Promise<Response> {
|
|
173
|
+
if (req.method !== "PUT") {
|
|
174
|
+
return jsonError(405, "method_not_allowed", "use PUT");
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
180
|
+
}
|
|
181
|
+
const parsed = await parseSetCapBody(req);
|
|
182
|
+
if (!parsed.ok) {
|
|
183
|
+
return jsonError(parsed.status, parsed.error, parsed.description);
|
|
184
|
+
}
|
|
185
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
186
|
+
const known = new Set(listVaultNamesFromPath(manifestPath));
|
|
187
|
+
if (!known.has(vaultName)) {
|
|
188
|
+
return jsonError(
|
|
189
|
+
400,
|
|
190
|
+
"vault_not_found",
|
|
191
|
+
`vault "${vaultName}" is not registered in services.json`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const cap = setVaultCap(deps.db, vaultName, parsed.body.cap_bytes);
|
|
195
|
+
console.log(`vault cap set: vault=${vaultName} cap_bytes=${cap.capBytes}`);
|
|
196
|
+
const wire: VaultCapWireShape = {
|
|
197
|
+
vault_name: cap.vaultName,
|
|
198
|
+
cap_bytes: cap.capBytes,
|
|
199
|
+
created_at: cap.createdAt,
|
|
200
|
+
updated_at: cap.updatedAt,
|
|
201
|
+
};
|
|
202
|
+
return new Response(JSON.stringify({ vault_cap: wire }), {
|
|
203
|
+
status: 200,
|
|
204
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/hub-server.ts
CHANGED
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
* /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
|
|
97
97
|
* /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
|
|
98
98
|
* /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
|
|
99
|
+
* /api/vault-caps (GET) → list vaults + persisted storage caps (host:admin)
|
|
100
|
+
* /api/vault-caps/<name> (PUT) → set/update a vault's storage cap (host:admin)
|
|
99
101
|
* /login (GET + POST) → operator password login
|
|
100
102
|
* /login/2fa (POST) → second-factor (TOTP/backup) step
|
|
101
103
|
* (hub#473; reached after a correct
|
|
@@ -221,6 +223,7 @@ import {
|
|
|
221
223
|
handleResetUserPassword,
|
|
222
224
|
handleUpdateUserVaults,
|
|
223
225
|
} from "./api-users.ts";
|
|
226
|
+
import { handleListVaultCaps, handleSetVaultCap } from "./api-vault-caps.ts";
|
|
224
227
|
import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
|
|
225
228
|
import {
|
|
226
229
|
CHROME_OPT_OUT_PREFIXES,
|
|
@@ -3351,6 +3354,30 @@ export function hubFetch(
|
|
|
3351
3354
|
});
|
|
3352
3355
|
}
|
|
3353
3356
|
|
|
3357
|
+
// Per-vault storage caps (B5 admin visibility / D-slice). GET lists every
|
|
3358
|
+
// vault from services.json joined with its persisted cap; PUT /:name
|
|
3359
|
+
// sets/updates a cap. host:admin-gated, same gate flavor as /api/users.
|
|
3360
|
+
if (pathname === "/api/vault-caps") {
|
|
3361
|
+
if (!getDb) return dbNotConfigured();
|
|
3362
|
+
return handleListVaultCaps(req, {
|
|
3363
|
+
db: getDb(),
|
|
3364
|
+
issuer: oauthDeps(req).issuer,
|
|
3365
|
+
manifestPath,
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
if (pathname.startsWith("/api/vault-caps/")) {
|
|
3369
|
+
if (!getDb) return dbNotConfigured();
|
|
3370
|
+
const name = decodeURIComponent(pathname.slice("/api/vault-caps/".length));
|
|
3371
|
+
if (!name || name.includes("/")) {
|
|
3372
|
+
return new Response("not found", { status: 404 });
|
|
3373
|
+
}
|
|
3374
|
+
return handleSetVaultCap(req, name, {
|
|
3375
|
+
db: getDb(),
|
|
3376
|
+
issuer: oauthDeps(req).issuer,
|
|
3377
|
+
manifestPath,
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3354
3381
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
3355
3382
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
3356
3383
|
// via #231-followup so the URL reflects the surface's actual scope
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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-
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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-
|
|
256
|
-
*
|
|
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
|
|
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
|
/**
|