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.
- package/README.md +8 -0
- package/dist/server/auth/loginThrottle.d.ts +62 -0
- package/dist/server/auth/loginThrottle.js +63 -1
- package/dist/server/auth/loginThrottleDurable.d.ts +120 -0
- package/dist/server/auth/loginThrottleDurable.js +231 -0
- package/dist/server/worker-argon2/_nodeRefuses.d.ts +1 -0
- package/dist/server/worker-argon2/_nodeRefuses.js +13 -0
- package/package.json +8 -1
- package/src/leafSubpathsImportNothing.spec.ts +10 -0
- package/src/server/auth/loginThrottle.spec.ts +82 -0
- package/src/server/auth/loginThrottle.ts +108 -7
- package/src/server/auth/loginThrottleDurable.spec.ts +165 -0
- package/src/server/auth/loginThrottleDurable.ts +291 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +14 -0
- package/src/server/worker-argon2/_nodeRefuses.ts +16 -0
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
+
});
|