cursedbelt-server 4.24.1 → 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/package.json +7 -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/README.md
CHANGED
|
@@ -57,6 +57,14 @@ the Worker's `fetch` gives `Bun.password` to the auth code, backed by `argon2id`
|
|
|
57
57
|
files as static imports that wrangler bundles from `node_modules`. No wrangler rule and no local
|
|
58
58
|
`.wasm` declaration are needed. `src/server/worker-argon2/argon2.ts` has the measurements.
|
|
59
59
|
|
|
60
|
+
## 🔴 A login throttle on a Worker: `cursedbelt-server/login-throttle/durable`
|
|
61
|
+
|
|
62
|
+
`createLoginThrottle()` remembers in memory, and a Worker's memory is one isolate — per colo,
|
|
63
|
+
several per colo, wiped by every deploy — so a Worker's sign-in keeps its ledger in the
|
|
64
|
+
`LoginThrottleObject` Durable Object and asks it through `createRemoteLoginThrottle(env.<BINDING>,
|
|
65
|
+
{ ledger, fallback })`, gating with `attempt` (check and charge in one step, so a concurrent burst
|
|
66
|
+
cannot share one count). `src/server/auth/loginThrottleDurable.ts` has the why and the wiring.
|
|
67
|
+
|
|
60
68
|
## Standards
|
|
61
69
|
|
|
62
70
|
`docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` — the
|
|
@@ -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
|
*/
|
|
@@ -73,6 +92,26 @@ export interface LoginThrottleOptions {
|
|
|
73
92
|
windowMs?: number;
|
|
74
93
|
/** Hard cap on tracked keys, so the table cannot be a memory vector. Default 4096. */
|
|
75
94
|
maxKeys?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Start from a persisted {@link LoginThrottle.snapshot} rather than empty — how a Durable
|
|
97
|
+
* Object (`./loginThrottleDurable.ts`) survives its own eviction. An unreadable snapshot
|
|
98
|
+
* (wrong version, wrong shape) starts EMPTY for the keys and is ignored, never thrown on.
|
|
99
|
+
*/
|
|
100
|
+
restore?: LoginThrottleSnapshot | null;
|
|
101
|
+
}
|
|
102
|
+
/** One key's (or the global counter's) ledger line — the unit {@link LoginThrottleSnapshot} holds. */
|
|
103
|
+
export interface LoginThrottleEntry {
|
|
104
|
+
failures: number;
|
|
105
|
+
/** When the current lockout ends; 0 when not locked. */
|
|
106
|
+
lockedUntil: number;
|
|
107
|
+
lastSeen: number;
|
|
108
|
+
}
|
|
109
|
+
/** A throttle's whole state as plain data, for a store that outlives the throttle. */
|
|
110
|
+
export interface LoginThrottleSnapshot {
|
|
111
|
+
v: 1;
|
|
112
|
+
global: LoginThrottleEntry;
|
|
113
|
+
/** Most recently seen first, `[normalizedKey, entry]`. */
|
|
114
|
+
entries: Array<[string, LoginThrottleEntry]>;
|
|
76
115
|
}
|
|
77
116
|
export type ThrottleVerdict = {
|
|
78
117
|
allowed: true;
|
|
@@ -84,6 +123,13 @@ export type ThrottleVerdict = {
|
|
|
84
123
|
export interface LoginThrottle {
|
|
85
124
|
/** May this key attempt a login right now? */
|
|
86
125
|
check(key: string): ThrottleVerdict;
|
|
126
|
+
/**
|
|
127
|
+
* `check`, and when allowed, charge a failure in the SAME step — so concurrent attempts
|
|
128
|
+
* cannot all be admitted against one count. Follow an allowed attempt with
|
|
129
|
+
* `recordSuccess` when the credential was right; a wrong one needs no further call.
|
|
130
|
+
* See the header's `attempt` section.
|
|
131
|
+
*/
|
|
132
|
+
attempt(key: string): ThrottleVerdict;
|
|
87
133
|
/** Record a failed attempt. Returns the verdict the NEXT attempt would get. */
|
|
88
134
|
recordFailure(key: string): ThrottleVerdict;
|
|
89
135
|
/** Record a success — clears this key and the global counter. */
|
|
@@ -92,7 +138,23 @@ export interface LoginThrottle {
|
|
|
92
138
|
reset(key: string): void;
|
|
93
139
|
/** Tracked-key count, for the bounded-growth test. */
|
|
94
140
|
readonly size: number;
|
|
141
|
+
/** The state as plain data — the `limit` most recently seen keys (default all) and the global counter. */
|
|
142
|
+
snapshot(limit?: number): LoginThrottleSnapshot;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The same ledger across a round trip — `createRemoteLoginThrottle` in
|
|
146
|
+
* `./loginThrottleDurable.ts`. A door that awaits every call takes either shape:
|
|
147
|
+
* {@link LoginThrottleLike}.
|
|
148
|
+
*/
|
|
149
|
+
export interface AsyncLoginThrottle {
|
|
150
|
+
check(key: string): Promise<ThrottleVerdict>;
|
|
151
|
+
attempt(key: string): Promise<ThrottleVerdict>;
|
|
152
|
+
recordFailure(key: string): Promise<ThrottleVerdict>;
|
|
153
|
+
recordSuccess(key: string): Promise<void>;
|
|
154
|
+
reset(key: string): Promise<void>;
|
|
95
155
|
}
|
|
156
|
+
/** What a sign-in handler should accept: the in-process throttle or the Durable Object's. */
|
|
157
|
+
export type LoginThrottleLike = LoginThrottle | AsyncLoginThrottle;
|
|
96
158
|
/**
|
|
97
159
|
* The client address to key on — the LAST `X-Forwarded-For` entry (what our proxy
|
|
98
160
|
* saw), falling back to `X-Real-IP`, the socket peer, and finally a shared constant.
|
|
@@ -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
|
*/
|
|
@@ -98,6 +117,16 @@ export function clientKeyOfContext(req, env) {
|
|
|
98
117
|
}
|
|
99
118
|
return clientKeyOf(req, address);
|
|
100
119
|
}
|
|
120
|
+
/** A persisted entry is trusted only as far as its three numbers are finite. */
|
|
121
|
+
const readEntry = (raw) => {
|
|
122
|
+
const e = raw;
|
|
123
|
+
if (!e || typeof e !== 'object')
|
|
124
|
+
return null;
|
|
125
|
+
const { failures, lockedUntil, lastSeen } = e;
|
|
126
|
+
if (![failures, lockedUntil, lastSeen].every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
127
|
+
return null;
|
|
128
|
+
return { failures: failures, lockedUntil: lockedUntil, lastSeen: lastSeen };
|
|
129
|
+
};
|
|
101
130
|
/**
|
|
102
131
|
* Keys are compared case- and whitespace-insensitively.
|
|
103
132
|
*
|
|
@@ -119,6 +148,21 @@ export function createLoginThrottle(options = {}) {
|
|
|
119
148
|
const maxKeys = options.maxKeys ?? 4096;
|
|
120
149
|
const attempts = new Map();
|
|
121
150
|
const global = { failures: 0, lockedUntil: 0, lastSeen: 0 };
|
|
151
|
+
const restored = options.restore;
|
|
152
|
+
if (restored && restored.v === 1) {
|
|
153
|
+
const g = readEntry(restored.global);
|
|
154
|
+
if (g)
|
|
155
|
+
Object.assign(global, g);
|
|
156
|
+
// Oldest first, so the Map's insertion order matches what it would have been live.
|
|
157
|
+
const entries = Array.isArray(restored.entries) ? [...restored.entries].reverse() : [];
|
|
158
|
+
for (const pair of entries) {
|
|
159
|
+
if (!Array.isArray(pair) || typeof pair[0] !== 'string')
|
|
160
|
+
continue;
|
|
161
|
+
const entry = readEntry(pair[1]);
|
|
162
|
+
if (entry)
|
|
163
|
+
attempts.set(normalizeKey(pair[0]), entry);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
122
166
|
/** Delay for the nth failure past a threshold: base·2^(n-1), capped. */
|
|
123
167
|
const delayFor = (failures, limit, cap) => {
|
|
124
168
|
const past = failures - limit;
|
|
@@ -162,10 +206,18 @@ export function createLoginThrottle(options = {}) {
|
|
|
162
206
|
}
|
|
163
207
|
return { allowed: true };
|
|
164
208
|
};
|
|
165
|
-
|
|
209
|
+
const api = {
|
|
166
210
|
check(key) {
|
|
167
211
|
return verdict(key, now());
|
|
168
212
|
},
|
|
213
|
+
attempt(key) {
|
|
214
|
+
const gate = verdict(key, now());
|
|
215
|
+
if (!gate.allowed)
|
|
216
|
+
return gate;
|
|
217
|
+
// Charged BEFORE the caller verifies anything — see the header's `attempt` section.
|
|
218
|
+
api.recordFailure(key);
|
|
219
|
+
return gate;
|
|
220
|
+
},
|
|
169
221
|
recordFailure(rawKey) {
|
|
170
222
|
const key = normalizeKey(rawKey);
|
|
171
223
|
const nowMs = now();
|
|
@@ -200,5 +252,15 @@ export function createLoginThrottle(options = {}) {
|
|
|
200
252
|
get size() {
|
|
201
253
|
return attempts.size;
|
|
202
254
|
},
|
|
255
|
+
snapshot(limit) {
|
|
256
|
+
const byRecency = [...attempts.entries()].sort((a, b) => b[1].lastSeen - a[1].lastSeen);
|
|
257
|
+
const kept = limit === undefined ? byRecency : byRecency.slice(0, Math.max(0, limit));
|
|
258
|
+
return {
|
|
259
|
+
v: 1,
|
|
260
|
+
global: { ...global },
|
|
261
|
+
entries: kept.map(([key, entry]) => [key, { ...entry }]),
|
|
262
|
+
};
|
|
263
|
+
},
|
|
203
264
|
};
|
|
265
|
+
return api;
|
|
204
266
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login throttle on a Cloudflare Worker: ONE Durable Object holds the ledger, every isolate
|
|
3
|
+
* asks it.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (2026-09-23, patterns task 2136) ─────────────────────────
|
|
6
|
+
* `createLoginThrottle` keeps its counts in memory, and its header accepts that because on the
|
|
7
|
+
* Mac "memory" is the one process an attacker cannot restart. On a Worker there is no such
|
|
8
|
+
* process. `apps/patterns`' Worker first built the throttle per REQUEST — every attempt was a
|
|
9
|
+
* first attempt, so the sign-in had no failed-login brake at all — and then per ISOLATE, which
|
|
10
|
+
* is still one ledger per colo (often several per colo), each wiped by every deploy: a guesser
|
|
11
|
+
* spread across N isolates gets N × the global allowance the header calls "the actual defense".
|
|
12
|
+
*
|
|
13
|
+
* A Durable Object is the exact shape of the fix: one instance addressed by name, requests to it
|
|
14
|
+
* serialised, so every isolate in every colo meets the same counts. It runs the SAME
|
|
15
|
+
* `createLoginThrottle` algorithm — nothing here re-decides a threshold or a delay — and persists
|
|
16
|
+
* the ledger to the object's storage after every change, so the object's own eviction (minutes
|
|
17
|
+
* idle, measured on patterns' live object) forgets nothing either.
|
|
18
|
+
*
|
|
19
|
+
* ── Why not D1 ──────────────────────────────────────────────────────────────
|
|
20
|
+
* The throttle is checked BEFORE argon2, on every attempt, and the point of checking first is
|
|
21
|
+
* that a refused attempt is cheap. A D1 read-modify-write per attempt is a query against the
|
|
22
|
+
* invocation's 1,000-query budget and, worse, not atomic across concurrent invocations: two
|
|
23
|
+
* guesses that snapshot the same count both write count+1. The object serialises for free.
|
|
24
|
+
*
|
|
25
|
+
* ── `attempt` is one operation, not two ─────────────────────────────────────
|
|
26
|
+
* `/throttle/attempt` checks and charges in one serialised step, so a burst of concurrent guesses
|
|
27
|
+
* is admitted only up to the threshold rather than all at once against one stale count. See
|
|
28
|
+
* `loginThrottle.ts`'s `attempt` section.
|
|
29
|
+
*
|
|
30
|
+
* ── When the object cannot be reached ───────────────────────────────────────
|
|
31
|
+
* {@link createRemoteLoginThrottle} takes a `fallback` — the isolate's own module-scope
|
|
32
|
+
* throttle — and MIRRORS every write into it. The object is the authority whenever it answers;
|
|
33
|
+
* when a call throws or answers non-2xx, the fallback decides and the error is logged. So an
|
|
34
|
+
* outage degrades the door to exactly the per-isolate brake it had before, never to none, and
|
|
35
|
+
* never to a 500 the owner cannot sign in past. Without a `fallback` the error propagates.
|
|
36
|
+
*
|
|
37
|
+
* ── Types are structural ────────────────────────────────────────────────────
|
|
38
|
+
* No `@cloudflare/workers-types`: an app whose `src/server` also runs under Bun cannot take Worker
|
|
39
|
+
* globals over its whole type graph (patterns measured five broken `randomBytes(n)` calls the one
|
|
40
|
+
* time it tried). The object uses the classic `fetch` interface for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
import { type AsyncLoginThrottle, type LoginThrottle, type LoginThrottleOptions } from './loginThrottle.js';
|
|
43
|
+
/** The name every isolate addresses unless told otherwise — one object, one ledger set. */
|
|
44
|
+
export declare const LOGIN_THROTTLE_OBJECT_NAME = "login-throttle";
|
|
45
|
+
/**
|
|
46
|
+
* Every operation the object answers, `[method, path]` — for an app's CPU-budget census, which
|
|
47
|
+
* must declare each path a tail can deliver. `loginThrottleDurable.spec.ts` reds if the object
|
|
48
|
+
* answers a path not listed here, or stops answering one that is.
|
|
49
|
+
*/
|
|
50
|
+
export declare const LOGIN_THROTTLE_OPERATIONS: readonly [readonly ["POST", "/throttle/check"], readonly ["POST", "/throttle/attempt"], readonly ["POST", "/throttle/fail"], readonly ["POST", "/throttle/ok"], readonly ["POST", "/throttle/reset"]];
|
|
51
|
+
/**
|
|
52
|
+
* Keys persisted per ledger — the most recently seen. The in-memory table keeps its own
|
|
53
|
+
* `maxKeys` cap; this one keeps a stored value well under a Durable Object's per-value limit
|
|
54
|
+
* (128 KiB on the KV backend) at roughly 80 bytes a key. The global counter is always kept, and
|
|
55
|
+
* it is the counter a distributed guesser meets, so a key dropped here costs a courtesy layer
|
|
56
|
+
* only.
|
|
57
|
+
*/
|
|
58
|
+
export declare const PERSISTED_KEYS = 1024;
|
|
59
|
+
/** The two members of `DurableObjectStorage` this reads — the KV API, on either backend. */
|
|
60
|
+
export interface DurableStorageLike {
|
|
61
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
62
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/** The members of `DurableObjectState` this reads. */
|
|
65
|
+
export interface DurableStateLike {
|
|
66
|
+
storage: DurableStorageLike;
|
|
67
|
+
blockConcurrencyWhile?<T>(callback: () => Promise<T>): Promise<T>;
|
|
68
|
+
}
|
|
69
|
+
/** The members of a `DurableObjectNamespace` binding the client calls. */
|
|
70
|
+
export interface DurableNamespaceLike {
|
|
71
|
+
idFromName(name: string): unknown;
|
|
72
|
+
get(id: unknown): {
|
|
73
|
+
fetch(request: Request): Promise<Response>;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The Durable Object. Re-export it from the Worker's entry under the `class_name` your
|
|
78
|
+
* `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
|
|
79
|
+
*
|
|
80
|
+
* export { LoginThrottleObject as PatternsThrottle } from "cursedbelt-server/login-throttle/durable";
|
|
81
|
+
*
|
|
82
|
+
* One object holds any number of named LEDGERS (`owner`, `probe`, …), each an independent
|
|
83
|
+
* `createLoginThrottle` persisted under `ledger:<name>`.
|
|
84
|
+
*/
|
|
85
|
+
export declare class LoginThrottleObject {
|
|
86
|
+
private readonly ledgers;
|
|
87
|
+
private readonly storage;
|
|
88
|
+
private readonly options;
|
|
89
|
+
/**
|
|
90
|
+
* `options` is for tests (a clock, a threshold); the runtime passes `(state, env)` only, and
|
|
91
|
+
* the throttle's own defaults are the fleet's.
|
|
92
|
+
*/
|
|
93
|
+
constructor(state: DurableStateLike, _env?: unknown, options?: LoginThrottleOptions);
|
|
94
|
+
/**
|
|
95
|
+
* The ledger, loaded once per object lifetime. The PROMISE is memoised, not the result, so two
|
|
96
|
+
* requests arriving while the first load is in flight share it rather than each restoring its
|
|
97
|
+
* own copy and one of them overwriting the other's charge.
|
|
98
|
+
*/
|
|
99
|
+
private ledger;
|
|
100
|
+
private persist;
|
|
101
|
+
fetch(request: Request): Promise<Response>;
|
|
102
|
+
}
|
|
103
|
+
export interface RemoteLoginThrottleOptions {
|
|
104
|
+
/** Which ledger in the object. Default `"default"`. A second ledger never spends the first's. */
|
|
105
|
+
ledger?: string;
|
|
106
|
+
/** Which object. Default {@link LOGIN_THROTTLE_OBJECT_NAME}. */
|
|
107
|
+
objectName?: string;
|
|
108
|
+
/**
|
|
109
|
+
* The isolate's own throttle, at MODULE scope — mirrored on every write and consulted only when
|
|
110
|
+
* the object cannot answer. See the header. Omitted → an unreachable object is an error.
|
|
111
|
+
*/
|
|
112
|
+
fallback?: LoginThrottle;
|
|
113
|
+
/** Where an unreachable-object line goes. Default `console.error`. */
|
|
114
|
+
onError?: (line: string) => void;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The client half: an {@link AsyncLoginThrottle} whose ledger lives in the object. Cheap to build —
|
|
118
|
+
* build it per request over `env.<BINDING>`; the state is on the other side of the call.
|
|
119
|
+
*/
|
|
120
|
+
export declare function createRemoteLoginThrottle(namespace: DurableNamespaceLike, options?: RemoteLoginThrottleOptions): AsyncLoginThrottle;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login throttle on a Cloudflare Worker: ONE Durable Object holds the ledger, every isolate
|
|
3
|
+
* asks it.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (2026-09-23, patterns task 2136) ─────────────────────────
|
|
6
|
+
* `createLoginThrottle` keeps its counts in memory, and its header accepts that because on the
|
|
7
|
+
* Mac "memory" is the one process an attacker cannot restart. On a Worker there is no such
|
|
8
|
+
* process. `apps/patterns`' Worker first built the throttle per REQUEST — every attempt was a
|
|
9
|
+
* first attempt, so the sign-in had no failed-login brake at all — and then per ISOLATE, which
|
|
10
|
+
* is still one ledger per colo (often several per colo), each wiped by every deploy: a guesser
|
|
11
|
+
* spread across N isolates gets N × the global allowance the header calls "the actual defense".
|
|
12
|
+
*
|
|
13
|
+
* A Durable Object is the exact shape of the fix: one instance addressed by name, requests to it
|
|
14
|
+
* serialised, so every isolate in every colo meets the same counts. It runs the SAME
|
|
15
|
+
* `createLoginThrottle` algorithm — nothing here re-decides a threshold or a delay — and persists
|
|
16
|
+
* the ledger to the object's storage after every change, so the object's own eviction (minutes
|
|
17
|
+
* idle, measured on patterns' live object) forgets nothing either.
|
|
18
|
+
*
|
|
19
|
+
* ── Why not D1 ──────────────────────────────────────────────────────────────
|
|
20
|
+
* The throttle is checked BEFORE argon2, on every attempt, and the point of checking first is
|
|
21
|
+
* that a refused attempt is cheap. A D1 read-modify-write per attempt is a query against the
|
|
22
|
+
* invocation's 1,000-query budget and, worse, not atomic across concurrent invocations: two
|
|
23
|
+
* guesses that snapshot the same count both write count+1. The object serialises for free.
|
|
24
|
+
*
|
|
25
|
+
* ── `attempt` is one operation, not two ─────────────────────────────────────
|
|
26
|
+
* `/throttle/attempt` checks and charges in one serialised step, so a burst of concurrent guesses
|
|
27
|
+
* is admitted only up to the threshold rather than all at once against one stale count. See
|
|
28
|
+
* `loginThrottle.ts`'s `attempt` section.
|
|
29
|
+
*
|
|
30
|
+
* ── When the object cannot be reached ───────────────────────────────────────
|
|
31
|
+
* {@link createRemoteLoginThrottle} takes a `fallback` — the isolate's own module-scope
|
|
32
|
+
* throttle — and MIRRORS every write into it. The object is the authority whenever it answers;
|
|
33
|
+
* when a call throws or answers non-2xx, the fallback decides and the error is logged. So an
|
|
34
|
+
* outage degrades the door to exactly the per-isolate brake it had before, never to none, and
|
|
35
|
+
* never to a 500 the owner cannot sign in past. Without a `fallback` the error propagates.
|
|
36
|
+
*
|
|
37
|
+
* ── Types are structural ────────────────────────────────────────────────────
|
|
38
|
+
* No `@cloudflare/workers-types`: an app whose `src/server` also runs under Bun cannot take Worker
|
|
39
|
+
* globals over its whole type graph (patterns measured five broken `randomBytes(n)` calls the one
|
|
40
|
+
* time it tried). The object uses the classic `fetch` interface for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
import { createLoginThrottle, } from './loginThrottle.js';
|
|
43
|
+
/** The name every isolate addresses unless told otherwise — one object, one ledger set. */
|
|
44
|
+
export const LOGIN_THROTTLE_OBJECT_NAME = 'login-throttle';
|
|
45
|
+
/**
|
|
46
|
+
* Every operation the object answers, `[method, path]` — for an app's CPU-budget census, which
|
|
47
|
+
* must declare each path a tail can deliver. `loginThrottleDurable.spec.ts` reds if the object
|
|
48
|
+
* answers a path not listed here, or stops answering one that is.
|
|
49
|
+
*/
|
|
50
|
+
export const LOGIN_THROTTLE_OPERATIONS = [
|
|
51
|
+
['POST', '/throttle/check'],
|
|
52
|
+
['POST', '/throttle/attempt'],
|
|
53
|
+
['POST', '/throttle/fail'],
|
|
54
|
+
['POST', '/throttle/ok'],
|
|
55
|
+
['POST', '/throttle/reset'],
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* Keys persisted per ledger — the most recently seen. The in-memory table keeps its own
|
|
59
|
+
* `maxKeys` cap; this one keeps a stored value well under a Durable Object's per-value limit
|
|
60
|
+
* (128 KiB on the KV backend) at roughly 80 bytes a key. The global counter is always kept, and
|
|
61
|
+
* it is the counter a distributed guesser meets, so a key dropped here costs a courtesy layer
|
|
62
|
+
* only.
|
|
63
|
+
*/
|
|
64
|
+
export const PERSISTED_KEYS = 1024;
|
|
65
|
+
/** A ledger is a short slug — the object keys its storage by it. */
|
|
66
|
+
const LEDGER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
67
|
+
/** Longer than any address or `<email>:<ip>`; a bound so a key cannot be a storage vector. */
|
|
68
|
+
const MAX_KEY_CHARS = 320;
|
|
69
|
+
const json = (value, status = 200) => new Response(JSON.stringify(value ?? null), {
|
|
70
|
+
status,
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* The Durable Object. Re-export it from the Worker's entry under the `class_name` your
|
|
75
|
+
* `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
|
|
76
|
+
*
|
|
77
|
+
* export { LoginThrottleObject as PatternsThrottle } from "cursedbelt-server/login-throttle/durable";
|
|
78
|
+
*
|
|
79
|
+
* One object holds any number of named LEDGERS (`owner`, `probe`, …), each an independent
|
|
80
|
+
* `createLoginThrottle` persisted under `ledger:<name>`.
|
|
81
|
+
*/
|
|
82
|
+
export class LoginThrottleObject {
|
|
83
|
+
ledgers = new Map();
|
|
84
|
+
storage;
|
|
85
|
+
options;
|
|
86
|
+
/**
|
|
87
|
+
* `options` is for tests (a clock, a threshold); the runtime passes `(state, env)` only, and
|
|
88
|
+
* the throttle's own defaults are the fleet's.
|
|
89
|
+
*/
|
|
90
|
+
constructor(state, _env, options = {}) {
|
|
91
|
+
this.storage = state.storage;
|
|
92
|
+
this.options = options;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The ledger, loaded once per object lifetime. The PROMISE is memoised, not the result, so two
|
|
96
|
+
* requests arriving while the first load is in flight share it rather than each restoring its
|
|
97
|
+
* own copy and one of them overwriting the other's charge.
|
|
98
|
+
*/
|
|
99
|
+
ledger(name) {
|
|
100
|
+
let loading = this.ledgers.get(name);
|
|
101
|
+
if (!loading) {
|
|
102
|
+
loading = this.storage
|
|
103
|
+
.get(`ledger:${name}`)
|
|
104
|
+
.then((restore) => createLoginThrottle({ ...this.options, restore: restore ?? null }));
|
|
105
|
+
// A failed load must not be cached as the ledger for the object's whole life.
|
|
106
|
+
loading.catch(() => this.ledgers.delete(name));
|
|
107
|
+
this.ledgers.set(name, loading);
|
|
108
|
+
}
|
|
109
|
+
return loading;
|
|
110
|
+
}
|
|
111
|
+
persist(name, throttle) {
|
|
112
|
+
return this.storage.put(`ledger:${name}`, throttle.snapshot(PERSISTED_KEYS));
|
|
113
|
+
}
|
|
114
|
+
async fetch(request) {
|
|
115
|
+
const path = new URL(request.url).pathname;
|
|
116
|
+
if (request.method !== 'POST')
|
|
117
|
+
return json({ error: `${request.method} ${path}: POST only` }, 405);
|
|
118
|
+
let body;
|
|
119
|
+
try {
|
|
120
|
+
body = (await request.json());
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return json({ error: 'body must be JSON {ledger, key}' }, 400);
|
|
124
|
+
}
|
|
125
|
+
const name = body?.ledger;
|
|
126
|
+
const key = body?.key;
|
|
127
|
+
if (typeof name !== 'string' || !LEDGER.test(name))
|
|
128
|
+
return json({ error: 'ledger must be a short slug' }, 400);
|
|
129
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_CHARS) {
|
|
130
|
+
return json({ error: `key must be 1–${MAX_KEY_CHARS} characters` }, 400);
|
|
131
|
+
}
|
|
132
|
+
const throttle = await this.ledger(name);
|
|
133
|
+
switch (path) {
|
|
134
|
+
case '/throttle/check':
|
|
135
|
+
return json(throttle.check(key));
|
|
136
|
+
case '/throttle/attempt': {
|
|
137
|
+
const verdict = throttle.attempt(key);
|
|
138
|
+
// A refused attempt changed nothing; an admitted one was charged and must be kept.
|
|
139
|
+
if (verdict.allowed)
|
|
140
|
+
await this.persist(name, throttle);
|
|
141
|
+
return json(verdict);
|
|
142
|
+
}
|
|
143
|
+
case '/throttle/fail': {
|
|
144
|
+
const verdict = throttle.recordFailure(key);
|
|
145
|
+
await this.persist(name, throttle);
|
|
146
|
+
return json(verdict);
|
|
147
|
+
}
|
|
148
|
+
case '/throttle/ok':
|
|
149
|
+
throttle.recordSuccess(key);
|
|
150
|
+
await this.persist(name, throttle);
|
|
151
|
+
return json({ ok: true });
|
|
152
|
+
case '/throttle/reset':
|
|
153
|
+
throttle.reset(key);
|
|
154
|
+
await this.persist(name, throttle);
|
|
155
|
+
return json({ ok: true });
|
|
156
|
+
default:
|
|
157
|
+
return json({ error: `no such throttle operation ${path}` }, 404);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The client half: an {@link AsyncLoginThrottle} whose ledger lives in the object. Cheap to build —
|
|
163
|
+
* build it per request over `env.<BINDING>`; the state is on the other side of the call.
|
|
164
|
+
*/
|
|
165
|
+
export function createRemoteLoginThrottle(namespace, options = {}) {
|
|
166
|
+
const ledger = options.ledger ?? 'default';
|
|
167
|
+
if (!LEDGER.test(ledger))
|
|
168
|
+
throw new Error(`login throttle ledger "${ledger}" must be a short slug`);
|
|
169
|
+
const objectName = options.objectName ?? LOGIN_THROTTLE_OBJECT_NAME;
|
|
170
|
+
const fallback = options.fallback;
|
|
171
|
+
const onError = options.onError ?? ((line) => console.error(line));
|
|
172
|
+
const call = async (path, key) => {
|
|
173
|
+
const stub = namespace.get(namespace.idFromName(objectName));
|
|
174
|
+
const response = await stub.fetch(new Request(`https://login-throttle.invalid${path}`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'content-type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ ledger, key }),
|
|
178
|
+
}));
|
|
179
|
+
if (!response.ok)
|
|
180
|
+
throw new Error(`${path} answered ${response.status}: ${await response.text()}`);
|
|
181
|
+
return (await response.json());
|
|
182
|
+
};
|
|
183
|
+
/** Ask the object; on failure, let the fallback answer (or rethrow when there is none). */
|
|
184
|
+
const remote = async (path, key, local) => {
|
|
185
|
+
try {
|
|
186
|
+
return await call(path, key);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!local)
|
|
190
|
+
throw error;
|
|
191
|
+
onError(`[login-throttle] the "${objectName}" object did not answer ${path} (${error instanceof Error ? error.message : String(error)}) — ` +
|
|
192
|
+
`this isolate's own ledger decides, which is per-isolate only`);
|
|
193
|
+
return local();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
check: (key) => remote('/throttle/check', key, fallback && (() => fallback.check(key))),
|
|
198
|
+
async attempt(key) {
|
|
199
|
+
let mirrored = false;
|
|
200
|
+
const verdict = await remote('/throttle/attempt', key, fallback &&
|
|
201
|
+
(() => {
|
|
202
|
+
mirrored = true;
|
|
203
|
+
return fallback.attempt(key);
|
|
204
|
+
}));
|
|
205
|
+
// The mirror: an admitted attempt the object charged is charged here too, so an outage
|
|
206
|
+
// later in this isolate's life starts from what it has seen rather than from zero.
|
|
207
|
+
if (!mirrored && verdict.allowed)
|
|
208
|
+
fallback?.recordFailure(key);
|
|
209
|
+
return verdict;
|
|
210
|
+
},
|
|
211
|
+
async recordFailure(key) {
|
|
212
|
+
let mirrored = false;
|
|
213
|
+
const verdict = await remote('/throttle/fail', key, fallback &&
|
|
214
|
+
(() => {
|
|
215
|
+
mirrored = true;
|
|
216
|
+
return fallback.recordFailure(key);
|
|
217
|
+
}));
|
|
218
|
+
if (!mirrored)
|
|
219
|
+
fallback?.recordFailure(key);
|
|
220
|
+
return verdict;
|
|
221
|
+
},
|
|
222
|
+
async recordSuccess(key) {
|
|
223
|
+
fallback?.recordSuccess(key);
|
|
224
|
+
await remote('/throttle/ok', key, fallback && (() => null));
|
|
225
|
+
},
|
|
226
|
+
async reset(key) {
|
|
227
|
+
fallback?.reset(key);
|
|
228
|
+
await remote('/throttle/reset', key, fallback && (() => null));
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.25.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -173,6 +173,12 @@
|
|
|
173
173
|
"source": "./src/server/auth/loginThrottle.ts",
|
|
174
174
|
"import": "./dist/server/auth/loginThrottle.js"
|
|
175
175
|
},
|
|
176
|
+
"./login-throttle/durable": {
|
|
177
|
+
"types": "./dist/server/auth/loginThrottleDurable.d.ts",
|
|
178
|
+
"bun": "./src/server/auth/loginThrottleDurable.ts",
|
|
179
|
+
"source": "./src/server/auth/loginThrottleDurable.ts",
|
|
180
|
+
"import": "./dist/server/auth/loginThrottleDurable.js"
|
|
181
|
+
},
|
|
176
182
|
"./maps-budget": {
|
|
177
183
|
"types": "./dist/server/maps-budget/mapsBudget.d.ts",
|
|
178
184
|
"bun": "./src/server/maps-budget/mapsBudget.ts",
|
|
@@ -124,6 +124,16 @@ const LEAVES = [
|
|
|
124
124
|
/** The failed-login backoff. Every app with a login door; shipped as 2.5.1. */
|
|
125
125
|
evaluates: 'createLoginThrottle',
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
subpath: './login-throttle/durable',
|
|
129
|
+
/**
|
|
130
|
+
* The throttle's Durable Object and its client — a Worker's ledger shared by every isolate
|
|
131
|
+
* (4.25.0, patterns task 2136). It runs on `workerd`, so it may import nothing but the
|
|
132
|
+
* throttle it serves; its types for the object's state and namespace are structural.
|
|
133
|
+
*/
|
|
134
|
+
evaluates: 'createRemoteLoginThrottle',
|
|
135
|
+
siblings: ['src/server/auth/loginThrottle.ts'],
|
|
136
|
+
},
|
|
127
137
|
{
|
|
128
138
|
subpath: './binary-store',
|
|
129
139
|
/**
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login throttle on a Cloudflare Worker: ONE Durable Object holds the ledger, every isolate
|
|
3
|
+
* asks it.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (2026-09-23, patterns task 2136) ─────────────────────────
|
|
6
|
+
* `createLoginThrottle` keeps its counts in memory, and its header accepts that because on the
|
|
7
|
+
* Mac "memory" is the one process an attacker cannot restart. On a Worker there is no such
|
|
8
|
+
* process. `apps/patterns`' Worker first built the throttle per REQUEST — every attempt was a
|
|
9
|
+
* first attempt, so the sign-in had no failed-login brake at all — and then per ISOLATE, which
|
|
10
|
+
* is still one ledger per colo (often several per colo), each wiped by every deploy: a guesser
|
|
11
|
+
* spread across N isolates gets N × the global allowance the header calls "the actual defense".
|
|
12
|
+
*
|
|
13
|
+
* A Durable Object is the exact shape of the fix: one instance addressed by name, requests to it
|
|
14
|
+
* serialised, so every isolate in every colo meets the same counts. It runs the SAME
|
|
15
|
+
* `createLoginThrottle` algorithm — nothing here re-decides a threshold or a delay — and persists
|
|
16
|
+
* the ledger to the object's storage after every change, so the object's own eviction (minutes
|
|
17
|
+
* idle, measured on patterns' live object) forgets nothing either.
|
|
18
|
+
*
|
|
19
|
+
* ── Why not D1 ──────────────────────────────────────────────────────────────
|
|
20
|
+
* The throttle is checked BEFORE argon2, on every attempt, and the point of checking first is
|
|
21
|
+
* that a refused attempt is cheap. A D1 read-modify-write per attempt is a query against the
|
|
22
|
+
* invocation's 1,000-query budget and, worse, not atomic across concurrent invocations: two
|
|
23
|
+
* guesses that snapshot the same count both write count+1. The object serialises for free.
|
|
24
|
+
*
|
|
25
|
+
* ── `attempt` is one operation, not two ─────────────────────────────────────
|
|
26
|
+
* `/throttle/attempt` checks and charges in one serialised step, so a burst of concurrent guesses
|
|
27
|
+
* is admitted only up to the threshold rather than all at once against one stale count. See
|
|
28
|
+
* `loginThrottle.ts`'s `attempt` section.
|
|
29
|
+
*
|
|
30
|
+
* ── When the object cannot be reached ───────────────────────────────────────
|
|
31
|
+
* {@link createRemoteLoginThrottle} takes a `fallback` — the isolate's own module-scope
|
|
32
|
+
* throttle — and MIRRORS every write into it. The object is the authority whenever it answers;
|
|
33
|
+
* when a call throws or answers non-2xx, the fallback decides and the error is logged. So an
|
|
34
|
+
* outage degrades the door to exactly the per-isolate brake it had before, never to none, and
|
|
35
|
+
* never to a 500 the owner cannot sign in past. Without a `fallback` the error propagates.
|
|
36
|
+
*
|
|
37
|
+
* ── Types are structural ────────────────────────────────────────────────────
|
|
38
|
+
* No `@cloudflare/workers-types`: an app whose `src/server` also runs under Bun cannot take Worker
|
|
39
|
+
* globals over its whole type graph (patterns measured five broken `randomBytes(n)` calls the one
|
|
40
|
+
* time it tried). The object uses the classic `fetch` interface for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
import {
|
|
43
|
+
createLoginThrottle,
|
|
44
|
+
type AsyncLoginThrottle,
|
|
45
|
+
type LoginThrottle,
|
|
46
|
+
type LoginThrottleOptions,
|
|
47
|
+
type LoginThrottleSnapshot,
|
|
48
|
+
type ThrottleVerdict,
|
|
49
|
+
} from './loginThrottle.js';
|
|
50
|
+
|
|
51
|
+
/** The name every isolate addresses unless told otherwise — one object, one ledger set. */
|
|
52
|
+
export const LOGIN_THROTTLE_OBJECT_NAME = 'login-throttle';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every operation the object answers, `[method, path]` — for an app's CPU-budget census, which
|
|
56
|
+
* must declare each path a tail can deliver. `loginThrottleDurable.spec.ts` reds if the object
|
|
57
|
+
* answers a path not listed here, or stops answering one that is.
|
|
58
|
+
*/
|
|
59
|
+
export const LOGIN_THROTTLE_OPERATIONS = [
|
|
60
|
+
['POST', '/throttle/check'],
|
|
61
|
+
['POST', '/throttle/attempt'],
|
|
62
|
+
['POST', '/throttle/fail'],
|
|
63
|
+
['POST', '/throttle/ok'],
|
|
64
|
+
['POST', '/throttle/reset'],
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keys persisted per ledger — the most recently seen. The in-memory table keeps its own
|
|
69
|
+
* `maxKeys` cap; this one keeps a stored value well under a Durable Object's per-value limit
|
|
70
|
+
* (128 KiB on the KV backend) at roughly 80 bytes a key. The global counter is always kept, and
|
|
71
|
+
* it is the counter a distributed guesser meets, so a key dropped here costs a courtesy layer
|
|
72
|
+
* only.
|
|
73
|
+
*/
|
|
74
|
+
export const PERSISTED_KEYS = 1024;
|
|
75
|
+
|
|
76
|
+
/** A ledger is a short slug — the object keys its storage by it. */
|
|
77
|
+
const LEDGER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
78
|
+
/** Longer than any address or `<email>:<ip>`; a bound so a key cannot be a storage vector. */
|
|
79
|
+
const MAX_KEY_CHARS = 320;
|
|
80
|
+
|
|
81
|
+
/** The two members of `DurableObjectStorage` this reads — the KV API, on either backend. */
|
|
82
|
+
export interface DurableStorageLike {
|
|
83
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
84
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The members of `DurableObjectState` this reads. */
|
|
88
|
+
export interface DurableStateLike {
|
|
89
|
+
storage: DurableStorageLike;
|
|
90
|
+
blockConcurrencyWhile?<T>(callback: () => Promise<T>): Promise<T>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The members of a `DurableObjectNamespace` binding the client calls. */
|
|
94
|
+
export interface DurableNamespaceLike {
|
|
95
|
+
idFromName(name: string): unknown;
|
|
96
|
+
get(id: unknown): { fetch(request: Request): Promise<Response> };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const json = (value: unknown, status = 200): Response =>
|
|
100
|
+
new Response(JSON.stringify(value ?? null), {
|
|
101
|
+
status,
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The Durable Object. Re-export it from the Worker's entry under the `class_name` your
|
|
107
|
+
* `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
|
|
108
|
+
*
|
|
109
|
+
* export { LoginThrottleObject as PatternsThrottle } from "cursedbelt-server/login-throttle/durable";
|
|
110
|
+
*
|
|
111
|
+
* One object holds any number of named LEDGERS (`owner`, `probe`, …), each an independent
|
|
112
|
+
* `createLoginThrottle` persisted under `ledger:<name>`.
|
|
113
|
+
*/
|
|
114
|
+
export class LoginThrottleObject {
|
|
115
|
+
private readonly ledgers = new Map<string, Promise<LoginThrottle>>();
|
|
116
|
+
private readonly storage: DurableStorageLike;
|
|
117
|
+
private readonly options: LoginThrottleOptions;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `options` is for tests (a clock, a threshold); the runtime passes `(state, env)` only, and
|
|
121
|
+
* the throttle's own defaults are the fleet's.
|
|
122
|
+
*/
|
|
123
|
+
constructor(state: DurableStateLike, _env?: unknown, options: LoginThrottleOptions = {}) {
|
|
124
|
+
this.storage = state.storage;
|
|
125
|
+
this.options = options;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The ledger, loaded once per object lifetime. The PROMISE is memoised, not the result, so two
|
|
130
|
+
* requests arriving while the first load is in flight share it rather than each restoring its
|
|
131
|
+
* own copy and one of them overwriting the other's charge.
|
|
132
|
+
*/
|
|
133
|
+
private ledger(name: string): Promise<LoginThrottle> {
|
|
134
|
+
let loading = this.ledgers.get(name);
|
|
135
|
+
if (!loading) {
|
|
136
|
+
loading = this.storage
|
|
137
|
+
.get<LoginThrottleSnapshot>(`ledger:${name}`)
|
|
138
|
+
.then((restore) => createLoginThrottle({ ...this.options, restore: restore ?? null }));
|
|
139
|
+
// A failed load must not be cached as the ledger for the object's whole life.
|
|
140
|
+
loading.catch(() => this.ledgers.delete(name));
|
|
141
|
+
this.ledgers.set(name, loading);
|
|
142
|
+
}
|
|
143
|
+
return loading;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private persist(name: string, throttle: LoginThrottle): Promise<void> {
|
|
147
|
+
return this.storage.put(`ledger:${name}`, throttle.snapshot(PERSISTED_KEYS));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async fetch(request: Request): Promise<Response> {
|
|
151
|
+
const path = new URL(request.url).pathname;
|
|
152
|
+
if (request.method !== 'POST') return json({ error: `${request.method} ${path}: POST only` }, 405);
|
|
153
|
+
let body: { ledger?: unknown; key?: unknown };
|
|
154
|
+
try {
|
|
155
|
+
body = (await request.json()) as typeof body;
|
|
156
|
+
} catch {
|
|
157
|
+
return json({ error: 'body must be JSON {ledger, key}' }, 400);
|
|
158
|
+
}
|
|
159
|
+
const name = body?.ledger;
|
|
160
|
+
const key = body?.key;
|
|
161
|
+
if (typeof name !== 'string' || !LEDGER.test(name)) return json({ error: 'ledger must be a short slug' }, 400);
|
|
162
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_CHARS) {
|
|
163
|
+
return json({ error: `key must be 1–${MAX_KEY_CHARS} characters` }, 400);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const throttle = await this.ledger(name);
|
|
167
|
+
switch (path) {
|
|
168
|
+
case '/throttle/check':
|
|
169
|
+
return json(throttle.check(key));
|
|
170
|
+
case '/throttle/attempt': {
|
|
171
|
+
const verdict = throttle.attempt(key);
|
|
172
|
+
// A refused attempt changed nothing; an admitted one was charged and must be kept.
|
|
173
|
+
if (verdict.allowed) await this.persist(name, throttle);
|
|
174
|
+
return json(verdict);
|
|
175
|
+
}
|
|
176
|
+
case '/throttle/fail': {
|
|
177
|
+
const verdict = throttle.recordFailure(key);
|
|
178
|
+
await this.persist(name, throttle);
|
|
179
|
+
return json(verdict);
|
|
180
|
+
}
|
|
181
|
+
case '/throttle/ok':
|
|
182
|
+
throttle.recordSuccess(key);
|
|
183
|
+
await this.persist(name, throttle);
|
|
184
|
+
return json({ ok: true });
|
|
185
|
+
case '/throttle/reset':
|
|
186
|
+
throttle.reset(key);
|
|
187
|
+
await this.persist(name, throttle);
|
|
188
|
+
return json({ ok: true });
|
|
189
|
+
default:
|
|
190
|
+
return json({ error: `no such throttle operation ${path}` }, 404);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface RemoteLoginThrottleOptions {
|
|
196
|
+
/** Which ledger in the object. Default `"default"`. A second ledger never spends the first's. */
|
|
197
|
+
ledger?: string;
|
|
198
|
+
/** Which object. Default {@link LOGIN_THROTTLE_OBJECT_NAME}. */
|
|
199
|
+
objectName?: string;
|
|
200
|
+
/**
|
|
201
|
+
* The isolate's own throttle, at MODULE scope — mirrored on every write and consulted only when
|
|
202
|
+
* the object cannot answer. See the header. Omitted → an unreachable object is an error.
|
|
203
|
+
*/
|
|
204
|
+
fallback?: LoginThrottle;
|
|
205
|
+
/** Where an unreachable-object line goes. Default `console.error`. */
|
|
206
|
+
onError?: (line: string) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The client half: an {@link AsyncLoginThrottle} whose ledger lives in the object. Cheap to build —
|
|
211
|
+
* build it per request over `env.<BINDING>`; the state is on the other side of the call.
|
|
212
|
+
*/
|
|
213
|
+
export function createRemoteLoginThrottle(
|
|
214
|
+
namespace: DurableNamespaceLike,
|
|
215
|
+
options: RemoteLoginThrottleOptions = {},
|
|
216
|
+
): AsyncLoginThrottle {
|
|
217
|
+
const ledger = options.ledger ?? 'default';
|
|
218
|
+
if (!LEDGER.test(ledger)) throw new Error(`login throttle ledger "${ledger}" must be a short slug`);
|
|
219
|
+
const objectName = options.objectName ?? LOGIN_THROTTLE_OBJECT_NAME;
|
|
220
|
+
const fallback = options.fallback;
|
|
221
|
+
const onError = options.onError ?? ((line: string) => console.error(line));
|
|
222
|
+
|
|
223
|
+
const call = async <T>(path: string, key: string): Promise<T> => {
|
|
224
|
+
const stub = namespace.get(namespace.idFromName(objectName));
|
|
225
|
+
const response = await stub.fetch(
|
|
226
|
+
new Request(`https://login-throttle.invalid${path}`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'content-type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ ledger, key }),
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
if (!response.ok) throw new Error(`${path} answered ${response.status}: ${await response.text()}`);
|
|
233
|
+
return (await response.json()) as T;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Ask the object; on failure, let the fallback answer (or rethrow when there is none). */
|
|
237
|
+
const remote = async <T>(path: string, key: string, local: (() => T) | undefined): Promise<T> => {
|
|
238
|
+
try {
|
|
239
|
+
return await call<T>(path, key);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!local) throw error;
|
|
242
|
+
onError(
|
|
243
|
+
`[login-throttle] the "${objectName}" object did not answer ${path} (${error instanceof Error ? error.message : String(error)}) — ` +
|
|
244
|
+
`this isolate's own ledger decides, which is per-isolate only`,
|
|
245
|
+
);
|
|
246
|
+
return local();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
check: (key) => remote<ThrottleVerdict>('/throttle/check', key, fallback && (() => fallback.check(key))),
|
|
252
|
+
async attempt(key) {
|
|
253
|
+
let mirrored = false;
|
|
254
|
+
const verdict = await remote<ThrottleVerdict>(
|
|
255
|
+
'/throttle/attempt',
|
|
256
|
+
key,
|
|
257
|
+
fallback &&
|
|
258
|
+
(() => {
|
|
259
|
+
mirrored = true;
|
|
260
|
+
return fallback.attempt(key);
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
// The mirror: an admitted attempt the object charged is charged here too, so an outage
|
|
264
|
+
// later in this isolate's life starts from what it has seen rather than from zero.
|
|
265
|
+
if (!mirrored && verdict.allowed) fallback?.recordFailure(key);
|
|
266
|
+
return verdict;
|
|
267
|
+
},
|
|
268
|
+
async recordFailure(key) {
|
|
269
|
+
let mirrored = false;
|
|
270
|
+
const verdict = await remote<ThrottleVerdict>(
|
|
271
|
+
'/throttle/fail',
|
|
272
|
+
key,
|
|
273
|
+
fallback &&
|
|
274
|
+
(() => {
|
|
275
|
+
mirrored = true;
|
|
276
|
+
return fallback.recordFailure(key);
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
if (!mirrored) fallback?.recordFailure(key);
|
|
280
|
+
return verdict;
|
|
281
|
+
},
|
|
282
|
+
async recordSuccess(key) {
|
|
283
|
+
fallback?.recordSuccess(key);
|
|
284
|
+
await remote<unknown>('/throttle/ok', key, fallback && (() => null));
|
|
285
|
+
},
|
|
286
|
+
async reset(key) {
|
|
287
|
+
fallback?.reset(key);
|
|
288
|
+
await remote<unknown>('/throttle/reset', key, fallback && (() => null));
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|