@openparachute/hub 0.7.4-rc.15 → 0.7.4-rc.16
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-account-2fa.test.ts +72 -0
- package/src/api-account-2fa.ts +23 -1
- package/src/rate-limit.ts +28 -0
package/package.json
CHANGED
|
@@ -312,6 +312,78 @@ describe("/api/account/2fa start + confirm", () => {
|
|
|
312
312
|
const body = (await res.json()) as { error: string };
|
|
313
313
|
expect(body.error).toBe("setup_expired");
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
|
|
317
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
318
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
319
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
320
|
+
// 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
|
|
321
|
+
// bucket doesn't punish a fumbling enroller.
|
|
322
|
+
for (let i = 0; i < 10; i++) {
|
|
323
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
324
|
+
__csrf: TEST_CSRF,
|
|
325
|
+
secret,
|
|
326
|
+
code: "000000",
|
|
327
|
+
});
|
|
328
|
+
expect(r.status).toBe(400);
|
|
329
|
+
}
|
|
330
|
+
// 11th is denied by the limiter BEFORE the code is checked.
|
|
331
|
+
const denied = await post("/2fa/confirm", cookie, {
|
|
332
|
+
__csrf: TEST_CSRF,
|
|
333
|
+
secret,
|
|
334
|
+
code: "000000",
|
|
335
|
+
});
|
|
336
|
+
expect(denied.status).toBe(429);
|
|
337
|
+
const body = (await denied.json()) as { error: string };
|
|
338
|
+
expect(body.error).toBe("too_many_attempts");
|
|
339
|
+
expect(denied.headers.get("retry-after")).toBeTruthy();
|
|
340
|
+
// The grind never touched enrollment.
|
|
341
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
|
|
345
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
346
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
347
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
348
|
+
// 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
|
|
349
|
+
for (let i = 0; i < 10; i++) {
|
|
350
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
351
|
+
__csrf: TEST_CSRF,
|
|
352
|
+
secret: "not-base32!!",
|
|
353
|
+
code: "000000",
|
|
354
|
+
});
|
|
355
|
+
expect(r.status).toBe(400);
|
|
356
|
+
}
|
|
357
|
+
// Budget untouched — the legit live code still enrolls on the next attempt.
|
|
358
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
359
|
+
__csrf: TEST_CSRF,
|
|
360
|
+
secret,
|
|
361
|
+
code: liveCode(secret),
|
|
362
|
+
});
|
|
363
|
+
expect(ok.status).toBe(200);
|
|
364
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
|
|
368
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
369
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
370
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
371
|
+
for (let i = 0; i < 3; i++) {
|
|
372
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
373
|
+
__csrf: TEST_CSRF,
|
|
374
|
+
secret,
|
|
375
|
+
code: "000000",
|
|
376
|
+
});
|
|
377
|
+
expect(r.status).toBe(400);
|
|
378
|
+
}
|
|
379
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
380
|
+
__csrf: TEST_CSRF,
|
|
381
|
+
secret,
|
|
382
|
+
code: liveCode(secret),
|
|
383
|
+
});
|
|
384
|
+
expect(ok.status).toBe(200);
|
|
385
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
386
|
+
});
|
|
315
387
|
});
|
|
316
388
|
|
|
317
389
|
describe("/api/account/2fa/disable", () => {
|
package/src/api-account-2fa.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { Database } from "bun:sqlite";
|
|
|
40
40
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
41
41
|
import QRCode from "qrcode";
|
|
42
42
|
import { verifyCsrfToken } from "./csrf.ts";
|
|
43
|
-
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
43
|
+
import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
|
|
44
44
|
import { findActiveSession } from "./sessions.ts";
|
|
45
45
|
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
|
|
46
46
|
import {
|
|
@@ -220,6 +220,28 @@ async function handleConfirm(
|
|
|
220
220
|
if (isTotpEnrolled(deps.db, user.id)) {
|
|
221
221
|
return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
|
|
222
222
|
}
|
|
223
|
+
// Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
|
|
224
|
+
// by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
|
|
225
|
+
// defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
|
|
226
|
+
// junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
|
|
227
|
+
// code verify so the grind window is actually bounded. A SUCCESSFUL confirm
|
|
228
|
+
// also consumes one slot (checkAndRecord counts every attempt) — harmless,
|
|
229
|
+
// since an enrolled account 409s on any further confirm anyway.
|
|
230
|
+
const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
|
|
231
|
+
user.id,
|
|
232
|
+
deps.now ? deps.now() : new Date(),
|
|
233
|
+
);
|
|
234
|
+
if (!confirmLimited.allowed) {
|
|
235
|
+
const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
|
|
236
|
+
return json(
|
|
237
|
+
429,
|
|
238
|
+
{
|
|
239
|
+
error: "too_many_attempts",
|
|
240
|
+
error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
|
|
241
|
+
},
|
|
242
|
+
{ "retry-after": String(retryAfter) },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
223
245
|
if (!verifyTotpCode(secret, code)) {
|
|
224
246
|
return jsonError(
|
|
225
247
|
400,
|
package/src/rate-limit.ts
CHANGED
|
@@ -87,6 +87,21 @@ export const CHANGE_PASSWORD_WINDOW_MS = 5 * 60 * 1000;
|
|
|
87
87
|
* cookie attacker shouldn't get a 5-shot grind window.
|
|
88
88
|
*/
|
|
89
89
|
export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
|
|
90
|
+
/**
|
|
91
|
+
* `/api/account/2fa/confirm` (TOTP enrollment seal) window: 15 minutes. This is
|
|
92
|
+
* the SELF-only, already-session-authenticated enrollment step — the operator is
|
|
93
|
+
* typing the first live code off their own authenticator while it drifts into
|
|
94
|
+
* sync, so legitimate mistypes are common and must not be punished. The threat
|
|
95
|
+
* is only a hijacked session grinding the (client-held, not-yet-persisted)
|
|
96
|
+
* in-flight secret, which the 10^6 code space + replay cache already make
|
|
97
|
+
* effectively non-exploitable — so this is defense-in-depth, deliberately MORE
|
|
98
|
+
* generous than the 3/5-min change-password bucket. NOT the `/login/2fa` bucket:
|
|
99
|
+
* that one is the strict, pre-auth brute-force door (5/15-min); enrollment is a
|
|
100
|
+
* different, lower-risk surface and gets its own lenient bucket.
|
|
101
|
+
*/
|
|
102
|
+
export const TOTP_ENROLL_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
|
103
|
+
/** `/api/account/2fa/confirm` attempts allowed per window. 11th is denied. */
|
|
104
|
+
export const TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS = 10;
|
|
90
105
|
/**
|
|
91
106
|
* `/login/2fa` window length: 15 minutes — same as `/login`. The second-
|
|
92
107
|
* factor step (hub#473) sits behind a verified password + a short-lived
|
|
@@ -289,6 +304,18 @@ export const changePasswordRateLimiter = new RateLimiter(
|
|
|
289
304
|
*/
|
|
290
305
|
export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
|
|
291
306
|
|
|
307
|
+
/**
|
|
308
|
+
* `/api/account/2fa/confirm` enrollment-seal bucket. Lenient (10 / 15 min),
|
|
309
|
+
* keyed by `user.id` (the session already establishes identity). Separate from
|
|
310
|
+
* `totpRateLimiter` so an enrollment mistype and a `/login/2fa` failure never
|
|
311
|
+
* share a window — different surfaces, different threat models (see the const
|
|
312
|
+
* docs above).
|
|
313
|
+
*/
|
|
314
|
+
export const totpEnrollConfirmRateLimiter = new RateLimiter(
|
|
315
|
+
TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS,
|
|
316
|
+
TOTP_ENROLL_CONFIRM_WINDOW_MS,
|
|
317
|
+
);
|
|
318
|
+
|
|
292
319
|
/**
|
|
293
320
|
* Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
|
|
294
321
|
* IP ONLY. Shared by all interactive auth doors (`/login`, the
|
|
@@ -342,6 +369,7 @@ export function __resetForTests(): void {
|
|
|
342
369
|
loginRateLimiter.reset();
|
|
343
370
|
changePasswordRateLimiter.reset();
|
|
344
371
|
totpRateLimiter.reset();
|
|
372
|
+
totpEnrollConfirmRateLimiter.reset();
|
|
345
373
|
vaultTokenMintRateLimiter.reset();
|
|
346
374
|
signupRateLimiter.reset();
|
|
347
375
|
authIpCeilingRateLimiter.reset();
|