cursedbelt-server 4.24.0 β†’ 4.25.0

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.
@@ -211,3 +211,85 @@ describe('createLoginThrottle', () => {
211
211
  expect(t.size).toBe(1);
212
212
  });
213
213
  });
214
+
215
+ describe('attempt β€” check and charge in one step', () => {
216
+ it('πŸ”΄ a burst of concurrent attempts is admitted only up to the threshold', async () => {
217
+ const c = clock();
218
+ const t = createLoginThrottle({ now: c.now, threshold: 3, globalThreshold: 1000 });
219
+ // Every attempt is admitted BEFORE any verify finishes β€” the shape of K concurrent guesses
220
+ // each waiting on argon2id. With check-then-recordFailure all ten would pass one stale count.
221
+ const verdicts = Array.from({ length: 10 }, () => t.attempt('ip'));
222
+ // Threshold 3 β†’ charges 1–3 free, the 4th arms the delay but is itself admitted (the same
223
+ // "one extra" the threshold option documents), the 5th onward are refused.
224
+ expect(verdicts.filter((v) => v.allowed).length).toBe(4);
225
+ expect(verdicts[4]).toEqual({ allowed: false, scope: 'ip', retryAfterSeconds: 1 });
226
+ });
227
+
228
+ it('a success after an attempt clears the charge', () => {
229
+ const c = clock();
230
+ const t = createLoginThrottle({ now: c.now, threshold: 1, globalThreshold: 1 });
231
+ expect(t.attempt('ip').allowed).toBe(true);
232
+ t.recordSuccess('ip');
233
+ expect(t.attempt('ip').allowed).toBe(true);
234
+ t.recordSuccess('ip');
235
+ expect(t.check('ip')).toEqual({ allowed: true });
236
+ expect(t.size).toBe(0);
237
+ });
238
+
239
+ it('a refused attempt is not itself charged', () => {
240
+ const c = clock();
241
+ const t = createLoginThrottle({ now: c.now, threshold: 0, baseDelayMs: 1_000, globalThreshold: 1000 });
242
+ expect(t.attempt('ip').allowed).toBe(true); // charged: failure 1 β†’ 1 s delay
243
+ for (let i = 0; i < 5; i++) expect(t.attempt('ip').allowed).toBe(false);
244
+ c.advance(1_000);
245
+ // Still failure 1's delay that just elapsed β€” not 2^5 s from five refused attempts.
246
+ expect(t.attempt('ip').allowed).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe('snapshot / restore', () => {
251
+ it('a throttle restored from a snapshot enforces what the original had seen', () => {
252
+ const c = clock();
253
+ const first = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 1000 });
254
+ for (let i = 0; i < 3; i++) first.recordFailure('Attacker');
255
+ expect(first.check('attacker').allowed).toBe(false);
256
+ const snap = JSON.parse(JSON.stringify(first.snapshot()));
257
+ const second = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 1000, restore: snap });
258
+ expect(second.check('attacker').allowed).toBe(false);
259
+ expect(second.check('someone-else').allowed).toBe(true);
260
+ // The count carries on rather than restarting.
261
+ c.advance(10_000);
262
+ const next = second.recordFailure('attacker');
263
+ expect(next.allowed).toBe(false);
264
+ if (!next.allowed) expect(next.retryAfterSeconds).toBe(2);
265
+ });
266
+
267
+ it('keeps the global counter, and only the most recent keys under a limit', () => {
268
+ const c = clock();
269
+ const t = createLoginThrottle({ now: c.now, globalThreshold: 3 });
270
+ for (let i = 0; i < 5; i++) {
271
+ c.advance(1);
272
+ t.recordFailure(`ip-${i}`);
273
+ }
274
+ const snap = t.snapshot(2);
275
+ expect(snap.entries.map(([k]) => k)).toEqual(['ip-4', 'ip-3']);
276
+ expect(snap.global.failures).toBe(5);
277
+ const restored = createLoginThrottle({ now: c.now, globalThreshold: 3, restore: snap });
278
+ const v = restored.check('a-new-ip');
279
+ expect(v.allowed).toBe(false);
280
+ if (!v.allowed) expect(v.scope).toBe('global');
281
+ });
282
+
283
+ it('an unreadable snapshot starts empty rather than throwing', () => {
284
+ const bad = [
285
+ { v: 2, global: { failures: 99, lockedUntil: 9e15, lastSeen: 0 }, entries: [] },
286
+ { v: 1, global: { failures: 'x' }, entries: [['k', { failures: null }], 'junk'] },
287
+ { v: 1, global: null, entries: null },
288
+ ];
289
+ for (const restore of bad) {
290
+ const t = createLoginThrottle({ restore: restore as never });
291
+ expect(t.check('k')).toEqual({ allowed: true });
292
+ expect(t.size).toBe(0);
293
+ }
294
+ });
295
+ });
@@ -45,6 +45,25 @@
45
45
  * global counter keeps working after eviction, which is precisely the case where it
46
46
  * matters most.
47
47
  *
48
+ * πŸ”΄ **"Per-process" is one ISOLATE on a Cloudflare Worker, and that is not a process.**
49
+ * Cloudflare runs an isolate per colo (often several), discards them without warning and
50
+ * builds a fresh one on every deploy, so a module-scope throttle there is global to one
51
+ * isolate only: a guesser reaching N isolates gets N Γ— `globalThreshold` free failures,
52
+ * and one built per REQUEST has no memory at all (patterns' Worker until 2026-09-23). A
53
+ * Worker keeps its ledger in a Durable Object instead β€” `./loginThrottleDurable.ts`
54
+ * (`cursedbelt-server/login-throttle/durable`), which serves THIS algorithm from one
55
+ * named object and persists it, so every isolate and every colo meets the same counts.
56
+ *
57
+ * ── `attempt`, not `check` then `recordFailure` ─────────────────────────────
58
+ * `check` β†’ verify β†’ `recordFailure` has a gap exactly as long as the verify: argon2id,
59
+ * hundreds of ms. Every request that arrives inside it reads the same count, so K
60
+ * concurrent guesses are all admitted and the counter learns of them afterwards β€” the
61
+ * throttle limits guesses per BURST, and the burst is as wide as the attacker's
62
+ * concurrency. `attempt` closes the gap: it checks and, when admitted, charges a
63
+ * failure in the same synchronous step, so the (threshold+1)th concurrent request is
64
+ * refused. A success then clears the charge (`recordSuccess`), and a failure needs no
65
+ * second call. The Durable Object serves `attempt` as one operation for the same reason.
66
+ *
48
67
  * Pair with the coarse per-IP `createRateLimiter` (`../middleware/rateLimit`); this is
49
68
  * the strict per-identity layer.
50
69
  */
@@ -74,6 +93,28 @@ export interface LoginThrottleOptions {
74
93
  windowMs?: number;
75
94
  /** Hard cap on tracked keys, so the table cannot be a memory vector. Default 4096. */
76
95
  maxKeys?: number;
96
+ /**
97
+ * Start from a persisted {@link LoginThrottle.snapshot} rather than empty β€” how a Durable
98
+ * Object (`./loginThrottleDurable.ts`) survives its own eviction. An unreadable snapshot
99
+ * (wrong version, wrong shape) starts EMPTY for the keys and is ignored, never thrown on.
100
+ */
101
+ restore?: LoginThrottleSnapshot | null;
102
+ }
103
+
104
+ /** One key's (or the global counter's) ledger line β€” the unit {@link LoginThrottleSnapshot} holds. */
105
+ export interface LoginThrottleEntry {
106
+ failures: number;
107
+ /** When the current lockout ends; 0 when not locked. */
108
+ lockedUntil: number;
109
+ lastSeen: number;
110
+ }
111
+
112
+ /** A throttle's whole state as plain data, for a store that outlives the throttle. */
113
+ export interface LoginThrottleSnapshot {
114
+ v: 1;
115
+ global: LoginThrottleEntry;
116
+ /** Most recently seen first, `[normalizedKey, entry]`. */
117
+ entries: Array<[string, LoginThrottleEntry]>;
77
118
  }
78
119
 
79
120
  export type ThrottleVerdict =
@@ -83,6 +124,13 @@ export type ThrottleVerdict =
83
124
  export interface LoginThrottle {
84
125
  /** May this key attempt a login right now? */
85
126
  check(key: string): ThrottleVerdict;
127
+ /**
128
+ * `check`, and when allowed, charge a failure in the SAME step β€” so concurrent attempts
129
+ * cannot all be admitted against one count. Follow an allowed attempt with
130
+ * `recordSuccess` when the credential was right; a wrong one needs no further call.
131
+ * See the header's `attempt` section.
132
+ */
133
+ attempt(key: string): ThrottleVerdict;
86
134
  /** Record a failed attempt. Returns the verdict the NEXT attempt would get. */
87
135
  recordFailure(key: string): ThrottleVerdict;
88
136
  /** Record a success β€” clears this key and the global counter. */
@@ -91,8 +139,26 @@ export interface LoginThrottle {
91
139
  reset(key: string): void;
92
140
  /** Tracked-key count, for the bounded-growth test. */
93
141
  readonly size: number;
142
+ /** The state as plain data β€” the `limit` most recently seen keys (default all) and the global counter. */
143
+ snapshot(limit?: number): LoginThrottleSnapshot;
94
144
  }
95
145
 
146
+ /**
147
+ * The same ledger across a round trip β€” `createRemoteLoginThrottle` in
148
+ * `./loginThrottleDurable.ts`. A door that awaits every call takes either shape:
149
+ * {@link LoginThrottleLike}.
150
+ */
151
+ export interface AsyncLoginThrottle {
152
+ check(key: string): Promise<ThrottleVerdict>;
153
+ attempt(key: string): Promise<ThrottleVerdict>;
154
+ recordFailure(key: string): Promise<ThrottleVerdict>;
155
+ recordSuccess(key: string): Promise<void>;
156
+ reset(key: string): Promise<void>;
157
+ }
158
+
159
+ /** What a sign-in handler should accept: the in-process throttle or the Durable Object's. */
160
+ export type LoginThrottleLike = LoginThrottle | AsyncLoginThrottle;
161
+
96
162
  /**
97
163
  * The client address to key on β€” the LAST `X-Forwarded-For` entry (what our proxy
98
164
  * saw), falling back to `X-Real-IP`, the socket peer, and finally a shared constant.
@@ -142,12 +208,16 @@ export function clientKeyOfContext(req: Request, env: unknown): string {
142
208
  return clientKeyOf(req, address);
143
209
  }
144
210
 
145
- interface Attempt {
146
- failures: number;
147
- /** When the current lockout ends; 0 when not locked. */
148
- lockedUntil: number;
149
- lastSeen: number;
150
- }
211
+ type Attempt = LoginThrottleEntry;
212
+
213
+ /** A persisted entry is trusted only as far as its three numbers are finite. */
214
+ const readEntry = (raw: unknown): Attempt | null => {
215
+ const e = raw as Partial<Attempt> | null | undefined;
216
+ if (!e || typeof e !== 'object') return null;
217
+ const { failures, lockedUntil, lastSeen } = e;
218
+ if (![failures, lockedUntil, lastSeen].every((n) => typeof n === 'number' && Number.isFinite(n))) return null;
219
+ return { failures: failures as number, lockedUntil: lockedUntil as number, lastSeen: lastSeen as number };
220
+ };
151
221
 
152
222
  /**
153
223
  * Keys are compared case- and whitespace-insensitively.
@@ -172,6 +242,18 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
172
242
 
173
243
  const attempts = new Map<string, Attempt>();
174
244
  const global: Attempt = { failures: 0, lockedUntil: 0, lastSeen: 0 };
245
+ const restored = options.restore;
246
+ if (restored && restored.v === 1) {
247
+ const g = readEntry(restored.global);
248
+ if (g) Object.assign(global, g);
249
+ // Oldest first, so the Map's insertion order matches what it would have been live.
250
+ const entries = Array.isArray(restored.entries) ? [...restored.entries].reverse() : [];
251
+ for (const pair of entries) {
252
+ if (!Array.isArray(pair) || typeof pair[0] !== 'string') continue;
253
+ const entry = readEntry(pair[1]);
254
+ if (entry) attempts.set(normalizeKey(pair[0]), entry);
255
+ }
256
+ }
175
257
 
176
258
  /** Delay for the nth failure past a threshold: baseΒ·2^(n-1), capped. */
177
259
  const delayFor = (failures: number, limit: number, cap: number): number => {
@@ -215,11 +297,19 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
215
297
  return { allowed: true };
216
298
  };
217
299
 
218
- return {
300
+ const api: LoginThrottle = {
219
301
  check(key) {
220
302
  return verdict(key, now());
221
303
  },
222
304
 
305
+ attempt(key) {
306
+ const gate = verdict(key, now());
307
+ if (!gate.allowed) return gate;
308
+ // Charged BEFORE the caller verifies anything β€” see the header's `attempt` section.
309
+ api.recordFailure(key);
310
+ return gate;
311
+ },
312
+
223
313
  recordFailure(rawKey) {
224
314
  const key = normalizeKey(rawKey);
225
315
  const nowMs = now();
@@ -257,5 +347,16 @@ export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginTh
257
347
  get size() {
258
348
  return attempts.size;
259
349
  },
350
+
351
+ snapshot(limit) {
352
+ const byRecency = [...attempts.entries()].sort((a, b) => b[1].lastSeen - a[1].lastSeen);
353
+ const kept = limit === undefined ? byRecency : byRecency.slice(0, Math.max(0, limit));
354
+ return {
355
+ v: 1,
356
+ global: { ...global },
357
+ entries: kept.map(([key, entry]) => [key, { ...entry }]),
358
+ };
359
+ },
260
360
  };
361
+ return api;
261
362
  }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createLoginThrottle } from './loginThrottle.js';
3
+ import {
4
+ createRemoteLoginThrottle,
5
+ type DurableNamespaceLike,
6
+ type DurableStorageLike,
7
+ LOGIN_THROTTLE_OPERATIONS,
8
+ LoginThrottleObject,
9
+ } from './loginThrottleDurable.js';
10
+
11
+ /** A Durable Object's storage, as a Map that survives the object β€” which is what storage is for. */
12
+ const memoryStorage = (): DurableStorageLike & { data: Map<string, unknown> } => {
13
+ const data = new Map<string, unknown>();
14
+ return {
15
+ data,
16
+ // Structured clone, as the runtime does β€” an object that kept a live reference would pass
17
+ // a persistence test without persisting anything.
18
+ get: async <T>(key: string) => (data.has(key) ? (structuredClone(data.get(key)) as T) : undefined),
19
+ put: async (key, value) => {
20
+ data.set(key, structuredClone(value));
21
+ },
22
+ };
23
+ };
24
+
25
+ const clock = (start = 5_000_000) => {
26
+ let t = start;
27
+ return { now: () => t, advance: (ms: number) => (t += ms) };
28
+ };
29
+
30
+ /** A namespace whose ONE object can be swapped β€” `evict()` is the platform recycling it. */
31
+ const namespaceOver = (storage: DurableStorageLike, now: () => number) => {
32
+ let object = new LoginThrottleObject({ storage }, {}, { now, threshold: 2, globalThreshold: 4 });
33
+ const ns: DurableNamespaceLike & { evict(): void; calls: number } = {
34
+ calls: 0,
35
+ idFromName: (name) => name,
36
+ get: () => ({
37
+ fetch: (request: Request) => {
38
+ ns.calls += 1;
39
+ return object.fetch(request);
40
+ },
41
+ }),
42
+ evict() {
43
+ object = new LoginThrottleObject({ storage }, {}, { now, threshold: 2, globalThreshold: 4 });
44
+ },
45
+ };
46
+ return ns;
47
+ };
48
+
49
+ describe('LoginThrottleObject + createRemoteLoginThrottle', () => {
50
+ it('πŸ”΄ two isolates share ONE ledger β€” failures in one lock the other', async () => {
51
+ const c = clock();
52
+ const ns = namespaceOver(memoryStorage(), c.now);
53
+ // Two isolates: each builds its own client (per request, as a Worker does) over one binding.
54
+ const isolateA = createRemoteLoginThrottle(ns, { ledger: 'owner' });
55
+ const isolateB = createRemoteLoginThrottle(ns, { ledger: 'owner' });
56
+ for (let i = 0; i < 3; i++) await isolateA.recordFailure('203.0.113.9');
57
+ const seenByB = await isolateB.check('203.0.113.9');
58
+ expect(seenByB.allowed).toBe(false);
59
+ // …and the per-isolate version of the same thing does not, which is the bug this replaces.
60
+ const perIsolateA = createLoginThrottle({ now: c.now, threshold: 2 });
61
+ const perIsolateB = createLoginThrottle({ now: c.now, threshold: 2 });
62
+ for (let i = 0; i < 3; i++) perIsolateA.recordFailure('203.0.113.9');
63
+ expect(perIsolateB.check('203.0.113.9').allowed).toBe(true);
64
+ });
65
+
66
+ it('πŸ”΄ the ledger survives the object being evicted and rebuilt over the same storage', async () => {
67
+ const c = clock();
68
+ const storage = memoryStorage();
69
+ const ns = namespaceOver(storage, c.now);
70
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner' });
71
+ for (let i = 0; i < 3; i++) await t.recordFailure('192.0.2.44');
72
+ ns.evict();
73
+ expect((await t.check('192.0.2.44')).allowed).toBe(false);
74
+ // The global counter too: a fifth failure from anywhere arms the global delay (threshold 4).
75
+ await t.recordFailure('198.51.100.1');
76
+ ns.evict();
77
+ await t.recordFailure('198.51.100.2');
78
+ ns.evict();
79
+ const v = await t.check('a-fresh-address');
80
+ expect(v.allowed).toBe(false);
81
+ if (!v.allowed) expect(v.scope).toBe('global');
82
+ });
83
+
84
+ it('lockout after N failures, and a success clears it everywhere', async () => {
85
+ const c = clock();
86
+ const ns = namespaceOver(memoryStorage(), c.now);
87
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner' });
88
+ // threshold 2: attempts 1–3 admitted (the third arms), the fourth refused.
89
+ const verdicts = [];
90
+ for (let i = 0; i < 4; i++) verdicts.push(await t.attempt('ip'));
91
+ expect(verdicts.map((v) => v.allowed)).toEqual([true, true, true, false]);
92
+ c.advance(60_000);
93
+ expect((await t.attempt('ip')).allowed).toBe(true);
94
+ await t.recordSuccess('ip');
95
+ expect(await createRemoteLoginThrottle(ns, { ledger: 'owner' }).check('ip')).toEqual({ allowed: true });
96
+ });
97
+
98
+ it('πŸ”΄ a concurrent burst is admitted only up to the threshold, not all at once', async () => {
99
+ const c = clock();
100
+ const ns = namespaceOver(memoryStorage(), c.now);
101
+ const verdicts = await Promise.all(
102
+ Array.from({ length: 12 }, () => createRemoteLoginThrottle(ns, { ledger: 'owner' }).attempt('burst')),
103
+ );
104
+ expect(verdicts.filter((v) => v.allowed).length).toBe(3);
105
+ });
106
+
107
+ it('ledgers are independent β€” the probe never spends the owner', async () => {
108
+ const c = clock();
109
+ const ns = namespaceOver(memoryStorage(), c.now);
110
+ const probe = createRemoteLoginThrottle(ns, { ledger: 'probe' });
111
+ const owner = createRemoteLoginThrottle(ns, { ledger: 'owner' });
112
+ for (let i = 0; i < 20; i++) await probe.recordFailure('home');
113
+ expect((await probe.check('home')).allowed).toBe(false);
114
+ expect(await owner.attempt('home')).toEqual({ allowed: true });
115
+ });
116
+
117
+ it('an unreachable object degrades to the fallback, which was mirrored all along', async () => {
118
+ const c = clock();
119
+ const storage = memoryStorage();
120
+ const ns = namespaceOver(storage, c.now);
121
+ const fallback = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 4 });
122
+ const lines: string[] = [];
123
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner', fallback, onError: (l) => lines.push(l) });
124
+ for (let i = 0; i < 3; i++) await t.attempt('ip');
125
+ // The object goes away. The fallback has seen all three charges, so it refuses the fourth
126
+ // instead of starting from zero.
127
+ const broken: DurableNamespaceLike = {
128
+ idFromName: (n) => n,
129
+ get: () => ({ fetch: async () => new Response('overloaded', { status: 503 }) }),
130
+ };
131
+ const degraded = createRemoteLoginThrottle(broken, { ledger: 'owner', fallback, onError: (l) => lines.push(l) });
132
+ expect((await degraded.attempt('ip')).allowed).toBe(false);
133
+ expect(lines.length).toBe(1);
134
+ expect(lines[0]).toContain('503');
135
+ // Without a fallback the error is the caller's, never a silent allow.
136
+ await expect(createRemoteLoginThrottle(broken).check('ip')).rejects.toThrow('503');
137
+ });
138
+
139
+ it('answers exactly LOGIN_THROTTLE_OPERATIONS, and refuses malformed input', async () => {
140
+ const object = new LoginThrottleObject({ storage: memoryStorage() });
141
+ const post = (path: string, body: unknown) =>
142
+ object.fetch(new Request(`https://x.invalid${path}`, { method: 'POST', body: JSON.stringify(body) }));
143
+ for (const [, path] of LOGIN_THROTTLE_OPERATIONS) {
144
+ expect([path, (await post(path, { ledger: 'owner', key: 'k' })).status]).toEqual([path, 200]);
145
+ }
146
+ expect((await post('/throttle/nope', { ledger: 'owner', key: 'k' })).status).toBe(404);
147
+ expect((await post('/throttle/check', { ledger: 'Not A Slug', key: 'k' })).status).toBe(400);
148
+ expect((await post('/throttle/check', { ledger: 'owner', key: 'x'.repeat(321) })).status).toBe(400);
149
+ expect((await post('/throttle/check', { ledger: 'owner' })).status).toBe(400);
150
+ expect((await object.fetch(new Request('https://x.invalid/throttle/check'))).status).toBe(405);
151
+ });
152
+
153
+ it('persists a bounded ledger, however many keys it has seen', async () => {
154
+ const storage = memoryStorage();
155
+ const object = new LoginThrottleObject({ storage }, {}, { globalThreshold: 1e9, maxKeys: 5000 });
156
+ const t = createRemoteLoginThrottle(
157
+ { idFromName: (n) => n, get: () => ({ fetch: (r) => object.fetch(r) }) },
158
+ { ledger: 'owner' },
159
+ );
160
+ for (let i = 0; i < 1100; i++) await t.recordFailure(`10.0.${Math.floor(i / 250)}.${i % 250}`);
161
+ const saved = storage.data.get('ledger:owner') as { entries: unknown[] };
162
+ expect(saved.entries.length).toBe(1024);
163
+ expect(JSON.stringify(saved).length).toBeLessThan(128 * 1024);
164
+ });
165
+ });