@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/src/invites.ts
CHANGED
|
@@ -37,10 +37,23 @@
|
|
|
37
37
|
* v13): the redemption form shows it read-only and the redeem handler
|
|
38
38
|
* enforces it. NULL = redeemer picks their own.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* MULTI-USE (migration v15, DEMO-PREP-2026-06-25 Workstream B): an invite
|
|
41
|
+
* carries `max_uses` (how many accounts ONE link may create) + `used_count`
|
|
42
|
+
* (how many it has). A redeem is refused once `used_count >= max_uses`. A
|
|
43
|
+
* LEGACY single-use invite is exactly `max_uses = 1` (the column default), so
|
|
44
|
+
* pre-v15 invites and default-shaped new ones behave identically to before.
|
|
45
|
+
* `used_at` is RETAINED — stamped on the FIRST redeem — so the admin list's
|
|
46
|
+
* "redeemed" status and any single-use lookups keep reading the same signal.
|
|
47
|
+
* Exhaustion is enforced atomically in `recordInviteRedemption` (the
|
|
48
|
+
* `used_count < max_uses` guard in the UPDATE), so two concurrent redeems of
|
|
49
|
+
* the last remaining seat can't both succeed.
|
|
50
|
+
*
|
|
51
|
+
* Revocation is a separate `revoked_at` stamp the admin sets before
|
|
52
|
+
* redemption (terminal regardless of remaining uses). Expiry is enforced at
|
|
53
|
+
* redeem-time. A public-signup link also captures the redeemer's `email`
|
|
54
|
+
* (B2 — the contactable identity, also stored per-account on `users.email`)
|
|
55
|
+
* and may carry a `vault_cap_bytes` to stamp onto each provisioned vault (B4
|
|
56
|
+
* — persisted to `vault_caps` for the Phase-2 enforcement PR to read).
|
|
44
57
|
*/
|
|
45
58
|
import type { Database } from "bun:sqlite";
|
|
46
59
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -70,6 +83,24 @@ export interface Invite {
|
|
|
70
83
|
provisionVault: boolean;
|
|
71
84
|
/** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
|
|
72
85
|
defaultMirror: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* How many accounts this link may create (v15). 1 = legacy single-use
|
|
88
|
+
* (the column default); >1 = a multi-use public-signup link.
|
|
89
|
+
*/
|
|
90
|
+
maxUses: number;
|
|
91
|
+
/** How many accounts this link HAS created (v15). Redeem refused once == maxUses. */
|
|
92
|
+
usedCount: number;
|
|
93
|
+
/**
|
|
94
|
+
* Most-recent redeemer's email (v15, B2), or null. For a multi-use link
|
|
95
|
+
* the canonical per-account email lives on `users.email`; this column
|
|
96
|
+
* holds the latest redeemer's for the admin's at-a-glance audit.
|
|
97
|
+
*/
|
|
98
|
+
email: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
101
|
+
* (v15, B4), or null to provision uncapped (legacy behavior).
|
|
102
|
+
*/
|
|
103
|
+
vaultCapBytes: number | null;
|
|
73
104
|
expiresAt: string;
|
|
74
105
|
usedAt: string | null;
|
|
75
106
|
redeemedUserId: string | null;
|
|
@@ -98,6 +129,20 @@ export class InviteUsedError extends Error {
|
|
|
98
129
|
}
|
|
99
130
|
}
|
|
100
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A multi-use link whose seats are all taken (`used_count >= max_uses`). For
|
|
134
|
+
* a single-use (max_uses=1) link this is the same condition the legacy
|
|
135
|
+
* `InviteUsedError` named, but the redeem path throws `InviteUsedError` for a
|
|
136
|
+
* single-use link (familiar "already used" copy) and `InviteExhaustedError`
|
|
137
|
+
* for a multi-use one ("all signups taken") so the message can differ.
|
|
138
|
+
*/
|
|
139
|
+
export class InviteExhaustedError extends Error {
|
|
140
|
+
constructor() {
|
|
141
|
+
super("invite has reached its maximum number of signups");
|
|
142
|
+
this.name = "InviteExhaustedError";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
export class InviteRevokedError extends Error {
|
|
102
147
|
constructor() {
|
|
103
148
|
super("invite has been revoked");
|
|
@@ -113,6 +158,10 @@ interface Row {
|
|
|
113
158
|
role: string;
|
|
114
159
|
provision_vault: number;
|
|
115
160
|
default_mirror: string | null;
|
|
161
|
+
max_uses: number;
|
|
162
|
+
used_count: number;
|
|
163
|
+
email: string | null;
|
|
164
|
+
vault_cap_bytes: number | null;
|
|
116
165
|
expires_at: string;
|
|
117
166
|
used_at: string | null;
|
|
118
167
|
redeemed_user_id: string | null;
|
|
@@ -129,6 +178,10 @@ function rowToInvite(r: Row): Invite {
|
|
|
129
178
|
role: r.role,
|
|
130
179
|
provisionVault: r.provision_vault === 1,
|
|
131
180
|
defaultMirror: r.default_mirror,
|
|
181
|
+
maxUses: r.max_uses,
|
|
182
|
+
usedCount: r.used_count,
|
|
183
|
+
email: r.email,
|
|
184
|
+
vaultCapBytes: r.vault_cap_bytes,
|
|
132
185
|
expiresAt: r.expires_at,
|
|
133
186
|
usedAt: r.used_at,
|
|
134
187
|
redeemedUserId: r.redeemed_user_id,
|
|
@@ -142,10 +195,19 @@ export function hashInviteToken(rawToken: string): string {
|
|
|
142
195
|
return createHash("sha256").update(rawToken).digest("hex");
|
|
143
196
|
}
|
|
144
197
|
|
|
145
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Derive an invite's status from its stamps + the current time.
|
|
200
|
+
*
|
|
201
|
+
* Multi-use (v15): an invite is "redeemed" only once every seat is taken
|
|
202
|
+
* (`usedCount >= maxUses`). A partially-used multi-use link (some seats left,
|
|
203
|
+
* not expired/revoked) is still "pending" — it can take more signups. A
|
|
204
|
+
* legacy single-use link (maxUses=1) flips to "redeemed" on its first
|
|
205
|
+
* redeem, exactly as before, because 1 >= 1. Expired wins over a not-yet-
|
|
206
|
+
* exhausted link; revoked + exhausted are checked first (terminal).
|
|
207
|
+
*/
|
|
146
208
|
export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
|
|
147
209
|
if (invite.revokedAt) return "revoked";
|
|
148
|
-
if (invite.
|
|
210
|
+
if (invite.usedCount >= invite.maxUses) return "redeemed";
|
|
149
211
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
|
|
150
212
|
return "pending";
|
|
151
213
|
}
|
|
@@ -166,6 +228,16 @@ export interface IssueInviteOpts {
|
|
|
166
228
|
provisionVault?: boolean;
|
|
167
229
|
/** `'internal' | 'off'` mirror knob for the provisioned vault. */
|
|
168
230
|
defaultMirror?: string | null;
|
|
231
|
+
/**
|
|
232
|
+
* How many accounts this link may create (v15). Default 1 (single-use,
|
|
233
|
+
* the legacy shape). Caller validates the upper bound.
|
|
234
|
+
*/
|
|
235
|
+
maxUses?: number;
|
|
236
|
+
/**
|
|
237
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
238
|
+
* (v15, B4). Omit/null = provision uncapped (legacy behavior).
|
|
239
|
+
*/
|
|
240
|
+
vaultCapBytes?: number | null;
|
|
169
241
|
/** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
|
|
170
242
|
expiresInSeconds?: number;
|
|
171
243
|
now?: () => Date;
|
|
@@ -198,12 +270,15 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
198
270
|
const username = opts.username ?? null;
|
|
199
271
|
const provisionVault = opts.provisionVault ?? true;
|
|
200
272
|
const defaultMirror = opts.defaultMirror ?? null;
|
|
273
|
+
const maxUses = opts.maxUses ?? 1;
|
|
274
|
+
const vaultCapBytes = opts.vaultCapBytes ?? null;
|
|
201
275
|
|
|
202
276
|
db.prepare(
|
|
203
277
|
`INSERT INTO invites
|
|
204
278
|
(token, created_by, vault_name, username, role, provision_vault, default_mirror,
|
|
279
|
+
max_uses, used_count, email, vault_cap_bytes,
|
|
205
280
|
expires_at, used_at, redeemed_user_id, revoked_at, created_at)
|
|
206
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
281
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, NULL, NULL, NULL, ?)`,
|
|
207
282
|
).run(
|
|
208
283
|
tokenHash,
|
|
209
284
|
opts.createdBy,
|
|
@@ -212,6 +287,8 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
212
287
|
role,
|
|
213
288
|
provisionVault ? 1 : 0,
|
|
214
289
|
defaultMirror,
|
|
290
|
+
maxUses,
|
|
291
|
+
vaultCapBytes,
|
|
215
292
|
expiresAt,
|
|
216
293
|
createdAt,
|
|
217
294
|
);
|
|
@@ -226,6 +303,10 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
226
303
|
role,
|
|
227
304
|
provisionVault,
|
|
228
305
|
defaultMirror,
|
|
306
|
+
maxUses,
|
|
307
|
+
usedCount: 0,
|
|
308
|
+
email: null,
|
|
309
|
+
vaultCapBytes,
|
|
229
310
|
expiresAt,
|
|
230
311
|
usedAt: null,
|
|
231
312
|
redeemedUserId: null,
|
|
@@ -275,7 +356,7 @@ export function usernameReservedByPendingInvite(
|
|
|
275
356
|
const row = db
|
|
276
357
|
.query<{ token: string }, [string, string]>(
|
|
277
358
|
`SELECT token FROM invites
|
|
278
|
-
WHERE username = ? AND
|
|
359
|
+
WHERE username = ? AND used_count < max_uses AND revoked_at IS NULL AND expires_at > ?
|
|
279
360
|
LIMIT 1`,
|
|
280
361
|
)
|
|
281
362
|
.get(username, now.toISOString());
|
|
@@ -295,11 +376,13 @@ export function listInvites(
|
|
|
295
376
|
}
|
|
296
377
|
|
|
297
378
|
/**
|
|
298
|
-
* Validate an invite for redemption WITHOUT
|
|
299
|
-
* not-redeemable branch (not-found / expired /
|
|
300
|
-
* invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
301
|
-
* bad token is rejected before any account/vault work), then does
|
|
302
|
-
*
|
|
379
|
+
* Validate an invite for redemption WITHOUT recording one. Throws on every
|
|
380
|
+
* not-redeemable branch (not-found / expired / exhausted / revoked). Returns
|
|
381
|
+
* the invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
382
|
+
* a bad token is rejected before any account/vault work), then does createUser,
|
|
383
|
+
* then `recordInviteRedemption` INSIDE the user-row transaction (the atomic
|
|
384
|
+
* seat-lock — the `< max_uses` UPDATE guard is what actually prevents an
|
|
385
|
+
* over-seat under concurrency; this early check is the fast-path rejection).
|
|
303
386
|
*/
|
|
304
387
|
export function assertInviteRedeemable(
|
|
305
388
|
db: Database,
|
|
@@ -308,10 +391,15 @@ export function assertInviteRedeemable(
|
|
|
308
391
|
): Invite {
|
|
309
392
|
const invite = findInviteByRawToken(db, rawToken);
|
|
310
393
|
if (!invite) throw new InviteNotFoundError();
|
|
311
|
-
// Revoked +
|
|
312
|
-
// so a revoked-then-expired invite reports the more specific reason.
|
|
394
|
+
// Revoked + exhausted are terminal regardless of clock; check them before
|
|
395
|
+
// expiry so a revoked-then-expired invite reports the more specific reason.
|
|
313
396
|
if (invite.revokedAt) throw new InviteRevokedError();
|
|
314
|
-
|
|
397
|
+
// Exhaustion (v15): seats all taken. A single-use link (maxUses=1) throws
|
|
398
|
+
// the familiar InviteUsedError ("already used"); a multi-use one throws
|
|
399
|
+
// InviteExhaustedError ("all signups taken") so the copy can differ.
|
|
400
|
+
if (invite.usedCount >= invite.maxUses) {
|
|
401
|
+
throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
|
|
402
|
+
}
|
|
315
403
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) {
|
|
316
404
|
throw new InviteExpiredError();
|
|
317
405
|
}
|
|
@@ -319,39 +407,75 @@ export function assertInviteRedeemable(
|
|
|
319
407
|
}
|
|
320
408
|
|
|
321
409
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* `used_at
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
410
|
+
* Record ONE redemption against an invite — the multi-use-aware consume
|
|
411
|
+
* (v15). Atomically:
|
|
412
|
+
* - increments `used_count` by 1,
|
|
413
|
+
* - stamps `used_at` on the FIRST redeem only (COALESCE keeps the original),
|
|
414
|
+
* - records the redeemer's `email` (B2) + `redeemed_user_id` as the
|
|
415
|
+
* MOST-RECENT redeemer (the canonical per-account email lives on
|
|
416
|
+
* `users.email`; this is the admin's at-a-glance latest).
|
|
417
|
+
*
|
|
418
|
+
* Exhaustion + revocation are enforced by the `used_count < max_uses AND
|
|
419
|
+
* revoked_at IS NULL` guard in the UPDATE: a racing redeem that would take a
|
|
420
|
+
* seat past the cap — or one racing a revoke — updates zero rows, and the
|
|
421
|
+
* caller treats that as exhausted/revoked. Race-safe because sqlite
|
|
422
|
+
* serializes writes (a legacy single-use link is just the max_uses=1 case:
|
|
423
|
+
* the second concurrent redeem sees `used_count`=1, fails the `< max_uses`
|
|
424
|
+
* guard, and changes zero rows — exactly the old single-use behavior).
|
|
329
425
|
*
|
|
330
|
-
* Returns `true` if THIS call
|
|
426
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
331
427
|
*/
|
|
332
|
-
export function
|
|
428
|
+
export function recordInviteRedemption(
|
|
333
429
|
db: Database,
|
|
334
430
|
tokenHash: string,
|
|
335
431
|
redeemedUserId: string,
|
|
432
|
+
email: string | null = null,
|
|
336
433
|
now: Date = new Date(),
|
|
337
434
|
): boolean {
|
|
435
|
+
const stamp = now.toISOString();
|
|
338
436
|
const res = db
|
|
339
437
|
.prepare(
|
|
340
|
-
|
|
438
|
+
`UPDATE invites
|
|
439
|
+
SET used_count = used_count + 1,
|
|
440
|
+
used_at = COALESCE(used_at, ?),
|
|
441
|
+
redeemed_user_id = ?,
|
|
442
|
+
email = ?
|
|
443
|
+
WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL`,
|
|
341
444
|
)
|
|
342
|
-
.run(
|
|
445
|
+
.run(stamp, redeemedUserId, email, tokenHash);
|
|
343
446
|
return res.changes > 0;
|
|
344
447
|
}
|
|
345
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Legacy single-use consume — thin wrapper over {@link recordInviteRedemption}
|
|
451
|
+
* for callers that don't capture email. Retained for backwards compatibility;
|
|
452
|
+
* the redeem path uses `recordInviteRedemption` directly so it can record the
|
|
453
|
+
* email. Race-safe + exhaustion-aware via the underlying function.
|
|
454
|
+
*
|
|
455
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
456
|
+
*/
|
|
457
|
+
export function consumeInvite(
|
|
458
|
+
db: Database,
|
|
459
|
+
tokenHash: string,
|
|
460
|
+
redeemedUserId: string,
|
|
461
|
+
now: Date = new Date(),
|
|
462
|
+
): boolean {
|
|
463
|
+
return recordInviteRedemption(db, tokenHash, redeemedUserId, null, now);
|
|
464
|
+
}
|
|
465
|
+
|
|
346
466
|
/**
|
|
347
467
|
* Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
|
|
348
|
-
* invite
|
|
349
|
-
*
|
|
468
|
+
* invite still has seats left (`used_count < max_uses`) and isn't already
|
|
469
|
+
* revoked. A multi-use link can be revoked mid-life to cut off its remaining
|
|
470
|
+
* seats; a fully-exhausted link is terminal and revoke is a no-op. (For a
|
|
471
|
+
* legacy single-use link `used_count < max_uses` is equivalent to the old
|
|
472
|
+
* `used_at IS NULL` guard.) Returns `true` if this call revoked it, `false`
|
|
473
|
+
* if it was already exhausted/revoked or not found.
|
|
350
474
|
*/
|
|
351
475
|
export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
|
|
352
476
|
const res = db
|
|
353
477
|
.prepare(
|
|
354
|
-
"UPDATE invites SET revoked_at = ? WHERE token = ? AND
|
|
478
|
+
"UPDATE invites SET revoked_at = ? WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
355
479
|
)
|
|
356
480
|
.run(now.toISOString(), tokenHash);
|
|
357
481
|
return res.changes > 0;
|
|
@@ -373,7 +497,7 @@ export function revokeInvitesForVault(
|
|
|
373
497
|
): number {
|
|
374
498
|
const res = db
|
|
375
499
|
.prepare(
|
|
376
|
-
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND
|
|
500
|
+
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
377
501
|
)
|
|
378
502
|
.run(now.toISOString(), vaultName);
|
|
379
503
|
return Number(res.changes);
|
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
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rate-limit primitives for hub auth-surface endpoints.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Each limiter is a floor, not the primary defense. Two are highlighted below
|
|
5
|
+
* for their contrasting threat models; the rest (`/login/2fa`, vault-token
|
|
6
|
+
* mint, public signup) are documented at their `export const` definitions.
|
|
7
|
+
* The public-signup limiter is the deliberate OUTLIER — generous, not tight —
|
|
8
|
+
* because its endpoint is meant to be redeemed by a room of people sharing one
|
|
9
|
+
* egress IP (see `SIGNUP_MAX_ATTEMPTS`).
|
|
5
10
|
*
|
|
6
|
-
* - `/login` (per-
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.)
|
|
15
21
|
*
|
|
16
22
|
* - `/account/change-password` (per-user, hub#282): 3 attempts / 5 min.
|
|
17
23
|
* The endpoint is session-gated, so the threat model isn't open-
|
|
@@ -86,13 +92,27 @@ export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
|
|
|
86
92
|
* factor step (hub#473) sits behind a verified password + a short-lived
|
|
87
93
|
* pending-login token, so the threat model is "attacker who already has the
|
|
88
94
|
* password grinding 6-digit codes / backup codes." A 5-attempt / 15-min
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
92
100
|
*/
|
|
93
101
|
export const TOTP_WINDOW_MS = 15 * 60 * 1000;
|
|
94
102
|
/** `/login/2fa` attempts allowed per window. 6th within the window is denied. */
|
|
95
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;
|
|
96
116
|
/**
|
|
97
117
|
* `POST /account/vault-token/<name>` window length: 10 minutes. The endpoint
|
|
98
118
|
* is session-gated and assignment-capped (a friend can only mint
|
|
@@ -109,6 +129,27 @@ export const VAULT_TOKEN_MINT_WINDOW_MS = 10 * 60 * 1000;
|
|
|
109
129
|
* minutes is generous for a human and still chokes a stolen-cookie flood.
|
|
110
130
|
*/
|
|
111
131
|
export const VAULT_TOKEN_MINT_MAX_ATTEMPTS = 10;
|
|
132
|
+
/**
|
|
133
|
+
* `POST /account/setup/<token>` (public invite redemption / signup) window
|
|
134
|
+
* length: 15 minutes — same window as `/login`, but a MUCH larger cap (below)
|
|
135
|
+
* because the threat model is the opposite. A public multi-use signup link is
|
|
136
|
+
* meant to be redeemed by a ROOM OF PEOPLE, and a demo room shares ONE NAT'd
|
|
137
|
+
* egress IP — so a per-IP `/login`-sized cap (5/15min) would 429 the ~6th
|
|
138
|
+
* legitimate signer. This bucket is deliberately generous so a shared egress
|
|
139
|
+
* IP comfortably handles a cohort, while still being a floor against an
|
|
140
|
+
* attacker scripting account creation against an open link (the invite's
|
|
141
|
+
* own `max_uses` + expiry are the primary bound; this is the abuse floor).
|
|
142
|
+
* Separate bucket from `/login` so a signup flurry never burns the login
|
|
143
|
+
* window, and vice-versa.
|
|
144
|
+
*/
|
|
145
|
+
export const SIGNUP_WINDOW_MS = 15 * 60 * 1000;
|
|
146
|
+
/**
|
|
147
|
+
* `POST /account/setup/<token>` attempts allowed per IP per window. 60 is
|
|
148
|
+
* comfortably above a single demo room behind one NAT (and well above any
|
|
149
|
+
* legitimate human's retries) while still capping a scripted-abuse rate to
|
|
150
|
+
* ~4/min sustained. The 61st attempt within the window is denied.
|
|
151
|
+
*/
|
|
152
|
+
export const SIGNUP_MAX_ATTEMPTS = 60;
|
|
112
153
|
/** Sentinel for the IP-extraction priority chain when nothing parsed. */
|
|
113
154
|
export const UNKNOWN_IP_SENTINEL = "unknown";
|
|
114
155
|
|
|
@@ -209,9 +250,19 @@ export class RateLimiter {
|
|
|
209
250
|
}
|
|
210
251
|
|
|
211
252
|
/**
|
|
212
|
-
* `/login` rate limiter — per-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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.
|
|
215
266
|
*/
|
|
216
267
|
export const loginRateLimiter = new RateLimiter(MAX_ATTEMPTS, WINDOW_MS);
|
|
217
268
|
|
|
@@ -226,14 +277,30 @@ export const changePasswordRateLimiter = new RateLimiter(
|
|
|
226
277
|
);
|
|
227
278
|
|
|
228
279
|
/**
|
|
229
|
-
* `/login/2fa` rate limiter — per-
|
|
230
|
-
*
|
|
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
|
|
231
287
|
* bucket from `/login` so a password failure and a TOTP failure don't share a
|
|
232
|
-
* window
|
|
233
|
-
* as the password floor.
|
|
288
|
+
* window. The coarse per-IP `authIpCeilingRateLimiter` backstops this floor.
|
|
234
289
|
*/
|
|
235
290
|
export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
|
|
236
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
|
+
|
|
237
304
|
/**
|
|
238
305
|
* `POST /account/vault-token/<name>` rate limiter — per-user, 10 attempts /
|
|
239
306
|
* 10 min (friend vault-token mint). Keyed by user-id (session-gated endpoint,
|
|
@@ -246,6 +313,16 @@ export const vaultTokenMintRateLimiter = new RateLimiter(
|
|
|
246
313
|
VAULT_TOKEN_MINT_WINDOW_MS,
|
|
247
314
|
);
|
|
248
315
|
|
|
316
|
+
/**
|
|
317
|
+
* `POST /account/setup/<token>` rate limiter — per-IP, 60 attempts / 15 min
|
|
318
|
+
* (public invite redemption / signup). DELIBERATELY generous: a public
|
|
319
|
+
* multi-use signup link is redeemed by a room of people sharing one NAT'd
|
|
320
|
+
* egress IP, so a `/login`-sized cap would 429 legitimate signers mid-demo.
|
|
321
|
+
* Separate bucket from `/login` so the two never share a window. The invite's
|
|
322
|
+
* own `max_uses` + expiry are the primary bound; this is the abuse floor.
|
|
323
|
+
*/
|
|
324
|
+
export const signupRateLimiter = new RateLimiter(SIGNUP_MAX_ATTEMPTS, SIGNUP_WINDOW_MS);
|
|
325
|
+
|
|
249
326
|
/**
|
|
250
327
|
* Backwards-compat shim for hub#188's call sites: the original
|
|
251
328
|
* top-level `checkAndRecord` was the login limiter. New code should
|
|
@@ -266,6 +343,20 @@ export function __resetForTests(): void {
|
|
|
266
343
|
changePasswordRateLimiter.reset();
|
|
267
344
|
totpRateLimiter.reset();
|
|
268
345
|
vaultTokenMintRateLimiter.reset();
|
|
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()}`;
|
|
269
360
|
}
|
|
270
361
|
|
|
271
362
|
/**
|