@constructive-io/express-context 0.26.1 → 0.27.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/admission.d.ts +146 -0
- package/admission.js +249 -0
- package/esm/admission.js +242 -0
- package/esm/index.js +5 -1
- package/esm/loaders/index.js +4 -0
- package/esm/loaders/request-protection.js +146 -0
- package/esm/refusals.js +214 -0
- package/esm/request-protection.js +106 -0
- package/esm/usage-counter.js +190 -0
- package/index.d.ts +9 -1
- package/index.js +31 -1
- package/loaders/index.d.ts +2 -0
- package/loaders/index.js +6 -1
- package/loaders/request-protection.d.ts +22 -0
- package/loaders/request-protection.js +150 -0
- package/package.json +10 -10
- package/refusals.d.ts +154 -0
- package/refusals.js +223 -0
- package/request-protection.d.ts +117 -0
- package/request-protection.js +111 -0
- package/types.d.ts +2 -0
- package/usage-counter.d.ts +137 -0
- package/usage-counter.js +195 -0
package/admission.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admission — the two in-process bounds that decide whether a request is
|
|
3
|
+
* allowed to start at all.
|
|
4
|
+
*
|
|
5
|
+
* `request-protection` resolves the numbers; this module is what spends them.
|
|
6
|
+
* The three GUCs it hands to PostgreSQL bound how *long* one request may run,
|
|
7
|
+
* which is a different question from how *many* may run at once: fifty legal
|
|
8
|
+
* 20-second queries saturate the pool that serves every other tenant on the
|
|
9
|
+
* replica without any single one of them misbehaving. So there are two bounds
|
|
10
|
+
* here, and they protect different things:
|
|
11
|
+
*
|
|
12
|
+
* - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
|
|
13
|
+
* protected is *this process's* PostgreSQL pool, so the counter is
|
|
14
|
+
* deliberately in-process and `maxConcurrentRequests` is a per-replica
|
|
15
|
+
* budget. Coordinating it cluster-wide would need shared state on the hot
|
|
16
|
+
* path to bound something that is not shared.
|
|
17
|
+
*
|
|
18
|
+
* - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
|
|
19
|
+
* cannot answer abuse on a public API: the tenant's own budget is what an
|
|
20
|
+
* anonymous flood spends, so exhausting it turns the tenant's site off on
|
|
21
|
+
* the attacker's behalf. The per-caller bound is therefore keyed on the
|
|
22
|
+
* only discriminator an anonymous request has, and it fails closed —
|
|
23
|
+
* unlike a billing quota, which serves and records overage.
|
|
24
|
+
*
|
|
25
|
+
* Neither touches the database. A limiter that needs a row lock to decide
|
|
26
|
+
* whether to admit a request has made the saturation it exists to prevent
|
|
27
|
+
* cheaper to cause.
|
|
28
|
+
*/
|
|
29
|
+
/** Why a request was refused a slot. */
|
|
30
|
+
export type AdmissionRefusal =
|
|
31
|
+
/** The queue was already full, or the tenant chose not to wait. */
|
|
32
|
+
'queue_full'
|
|
33
|
+
/** The request waited `maxQueueWaitMs` and no slot came free. */
|
|
34
|
+
| 'queue_timeout';
|
|
35
|
+
/** The outcome of an {@link ConcurrencyLimiter.acquire} call. */
|
|
36
|
+
export interface AdmissionLease {
|
|
37
|
+
/** Whether the request may proceed. */
|
|
38
|
+
readonly granted: boolean;
|
|
39
|
+
/** How long the request waited for a slot, in milliseconds. */
|
|
40
|
+
readonly queuedMs: number;
|
|
41
|
+
/** Set when `granted` is false. */
|
|
42
|
+
readonly refusal?: AdmissionRefusal;
|
|
43
|
+
/**
|
|
44
|
+
* Return the slot. Idempotent, and a no-op on a refused lease, so a caller
|
|
45
|
+
* can release unconditionally in a `finally` and again on client abort
|
|
46
|
+
* without double-counting.
|
|
47
|
+
*/
|
|
48
|
+
release(): void;
|
|
49
|
+
}
|
|
50
|
+
/** What a single {@link ConcurrencyLimiter.acquire} is bounded by. */
|
|
51
|
+
export interface AdmissionRequest {
|
|
52
|
+
/** In-flight requests allowed for this key in this process. */
|
|
53
|
+
limit: number;
|
|
54
|
+
/** How long a request may wait for a slot. `0` means never queue. */
|
|
55
|
+
queueWaitMs: number;
|
|
56
|
+
/**
|
|
57
|
+
* How many requests may wait at once. Defaults to `limit`: a queue deeper
|
|
58
|
+
* than the number of slots draining it is latency nobody asked for, and an
|
|
59
|
+
* unbounded queue converts a saturated tenant into a memory leak.
|
|
60
|
+
*/
|
|
61
|
+
queueLimit?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Per-key in-flight counter with a bounded wait queue.
|
|
65
|
+
*
|
|
66
|
+
* A released slot is handed straight to the longest-waiting request rather
|
|
67
|
+
* than decremented and re-acquired, so a queue drains in arrival order and a
|
|
68
|
+
* burst of new arrivals cannot starve what is already waiting.
|
|
69
|
+
*/
|
|
70
|
+
export declare class ConcurrencyLimiter {
|
|
71
|
+
private readonly keys;
|
|
72
|
+
/**
|
|
73
|
+
* Take a slot for `key`, waiting up to `queueWaitMs` for one.
|
|
74
|
+
*
|
|
75
|
+
* Always resolves — a refusal is a value, not a throw, because the caller
|
|
76
|
+
* has to answer the client either way.
|
|
77
|
+
*/
|
|
78
|
+
acquire(key: string, request: AdmissionRequest): Promise<AdmissionLease>;
|
|
79
|
+
/** In-flight requests currently holding a slot for `key`. */
|
|
80
|
+
activeFor(key: string): number;
|
|
81
|
+
/** Requests currently waiting for a slot for `key`. */
|
|
82
|
+
queuedFor(key: string): number;
|
|
83
|
+
/** How many keys are being tracked — zero once every tenant goes idle. */
|
|
84
|
+
get trackedKeys(): number;
|
|
85
|
+
private lease;
|
|
86
|
+
/**
|
|
87
|
+
* Drop a key nobody is using, so the map is bounded by *live* tenants rather
|
|
88
|
+
* than by every tenant this replica has ever served.
|
|
89
|
+
*/
|
|
90
|
+
private evictIfIdle;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fixed-window request counter keyed by an arbitrary string.
|
|
94
|
+
*
|
|
95
|
+
* Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
|
|
96
|
+
* in constructive-db) rather than reinvented, so both edges refuse abuse the
|
|
97
|
+
* same way.
|
|
98
|
+
*/
|
|
99
|
+
export declare class RateWindow {
|
|
100
|
+
private readonly windowMs;
|
|
101
|
+
private readonly maxKeys;
|
|
102
|
+
private readonly counts;
|
|
103
|
+
constructor(windowMs?: number, maxKeys?: number);
|
|
104
|
+
/**
|
|
105
|
+
* Count one request against `key`; returns false once it is over `limit`.
|
|
106
|
+
*
|
|
107
|
+
* `limit` is per call rather than per instance because it comes from the
|
|
108
|
+
* tenant's settings, and one process serves many tenants.
|
|
109
|
+
*/
|
|
110
|
+
admit(key: string, limit: number, burst?: number): boolean;
|
|
111
|
+
/** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
|
|
112
|
+
retryAfterMs(key: string): number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The caller's address, read as far back through `X-Forwarded-For` as the
|
|
116
|
+
* deployment's own proxies reach — and no further.
|
|
117
|
+
*
|
|
118
|
+
* The header is caller-writable: whatever the client sends arrives at the left
|
|
119
|
+
* of the chain, and each proxy appends the peer it actually saw. So the
|
|
120
|
+
* rightmost entry is the one this deployment's edge observed and the leftmost
|
|
121
|
+
* is the one an abuser gets to choose. Taking the leftmost entry — the usual
|
|
122
|
+
* shape of this code — hands every attacker a fresh rate-limit key per request
|
|
123
|
+
* by sending a random `X-Forwarded-For`, which is worse than having no limiter
|
|
124
|
+
* at all, because it reads as one.
|
|
125
|
+
*
|
|
126
|
+
* `trustedProxyHops` is how many proxies of our own sit in front of the server:
|
|
127
|
+
* 0 means none (only the socket peer is believed, and the header is ignored
|
|
128
|
+
* outright), 1 means one ingress appended the client's address, and so on.
|
|
129
|
+
* Anything the chain cannot supply — an absent header, or a chain too short to
|
|
130
|
+
* have been written by all of them — falls back to the socket peer, which no
|
|
131
|
+
* caller can forge.
|
|
132
|
+
*/
|
|
133
|
+
export declare const clientIpFrom: (req: {
|
|
134
|
+
headers: Record<string, string | string[] | undefined>;
|
|
135
|
+
socket?: {
|
|
136
|
+
remoteAddress?: string;
|
|
137
|
+
};
|
|
138
|
+
}, trustedProxyHops: number) => string;
|
|
139
|
+
/**
|
|
140
|
+
* How many proxies of our own sit in front of the server.
|
|
141
|
+
*
|
|
142
|
+
* Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
|
|
143
|
+
* an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
|
|
144
|
+
* a higher number here would let a direct caller pick its own limiter key.
|
|
145
|
+
*/
|
|
146
|
+
export declare const trustedProxyHops: () => number;
|
package/admission.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* admission — the two in-process bounds that decide whether a request is
|
|
4
|
+
* allowed to start at all.
|
|
5
|
+
*
|
|
6
|
+
* `request-protection` resolves the numbers; this module is what spends them.
|
|
7
|
+
* The three GUCs it hands to PostgreSQL bound how *long* one request may run,
|
|
8
|
+
* which is a different question from how *many* may run at once: fifty legal
|
|
9
|
+
* 20-second queries saturate the pool that serves every other tenant on the
|
|
10
|
+
* replica without any single one of them misbehaving. So there are two bounds
|
|
11
|
+
* here, and they protect different things:
|
|
12
|
+
*
|
|
13
|
+
* - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
|
|
14
|
+
* protected is *this process's* PostgreSQL pool, so the counter is
|
|
15
|
+
* deliberately in-process and `maxConcurrentRequests` is a per-replica
|
|
16
|
+
* budget. Coordinating it cluster-wide would need shared state on the hot
|
|
17
|
+
* path to bound something that is not shared.
|
|
18
|
+
*
|
|
19
|
+
* - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
|
|
20
|
+
* cannot answer abuse on a public API: the tenant's own budget is what an
|
|
21
|
+
* anonymous flood spends, so exhausting it turns the tenant's site off on
|
|
22
|
+
* the attacker's behalf. The per-caller bound is therefore keyed on the
|
|
23
|
+
* only discriminator an anonymous request has, and it fails closed —
|
|
24
|
+
* unlike a billing quota, which serves and records overage.
|
|
25
|
+
*
|
|
26
|
+
* Neither touches the database. A limiter that needs a row lock to decide
|
|
27
|
+
* whether to admit a request has made the saturation it exists to prevent
|
|
28
|
+
* cheaper to cause.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.trustedProxyHops = exports.clientIpFrom = exports.RateWindow = exports.ConcurrencyLimiter = void 0;
|
|
32
|
+
const REFUSED = (refusal, queuedMs) => ({
|
|
33
|
+
granted: false,
|
|
34
|
+
queuedMs,
|
|
35
|
+
refusal,
|
|
36
|
+
release: () => { }
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Per-key in-flight counter with a bounded wait queue.
|
|
40
|
+
*
|
|
41
|
+
* A released slot is handed straight to the longest-waiting request rather
|
|
42
|
+
* than decremented and re-acquired, so a queue drains in arrival order and a
|
|
43
|
+
* burst of new arrivals cannot starve what is already waiting.
|
|
44
|
+
*/
|
|
45
|
+
class ConcurrencyLimiter {
|
|
46
|
+
keys = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Take a slot for `key`, waiting up to `queueWaitMs` for one.
|
|
49
|
+
*
|
|
50
|
+
* Always resolves — a refusal is a value, not a throw, because the caller
|
|
51
|
+
* has to answer the client either way.
|
|
52
|
+
*/
|
|
53
|
+
async acquire(key, request) {
|
|
54
|
+
const limit = Math.floor(request.limit);
|
|
55
|
+
// A non-positive limit is a misconfiguration, not "unlimited": the bounds
|
|
56
|
+
// in request-protection have a floor of 1, so reaching here with 0 means
|
|
57
|
+
// something bypassed them. Refuse rather than silently admit everything.
|
|
58
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
59
|
+
return REFUSED('queue_full', 0);
|
|
60
|
+
const state = this.keys.get(key) ?? { active: 0, queue: [] };
|
|
61
|
+
if (!this.keys.has(key))
|
|
62
|
+
this.keys.set(key, state);
|
|
63
|
+
if (state.active < limit) {
|
|
64
|
+
state.active += 1;
|
|
65
|
+
return this.lease(key, state, 0);
|
|
66
|
+
}
|
|
67
|
+
const queueLimit = Math.max(Math.floor(request.queueLimit ?? limit), 0);
|
|
68
|
+
const queueWaitMs = Math.max(request.queueWaitMs, 0);
|
|
69
|
+
if (queueWaitMs === 0 || state.queue.length >= queueLimit) {
|
|
70
|
+
this.evictIfIdle(key, state);
|
|
71
|
+
return REFUSED('queue_full', 0);
|
|
72
|
+
}
|
|
73
|
+
const waitStarted = Date.now();
|
|
74
|
+
const granted = await new Promise((resolve) => {
|
|
75
|
+
let settled = false;
|
|
76
|
+
const waiter = {
|
|
77
|
+
settle: (value) => {
|
|
78
|
+
if (settled)
|
|
79
|
+
return;
|
|
80
|
+
settled = true;
|
|
81
|
+
clearTimeout(waiter.timer);
|
|
82
|
+
resolve(value);
|
|
83
|
+
},
|
|
84
|
+
timer: setTimeout(() => {
|
|
85
|
+
const index = state.queue.indexOf(waiter);
|
|
86
|
+
if (index >= 0)
|
|
87
|
+
state.queue.splice(index, 1);
|
|
88
|
+
waiter.settle(false);
|
|
89
|
+
}, queueWaitMs)
|
|
90
|
+
};
|
|
91
|
+
state.queue.push(waiter);
|
|
92
|
+
});
|
|
93
|
+
const queuedMs = Date.now() - waitStarted;
|
|
94
|
+
if (!granted) {
|
|
95
|
+
this.evictIfIdle(key, state);
|
|
96
|
+
return REFUSED('queue_timeout', queuedMs);
|
|
97
|
+
}
|
|
98
|
+
// The slot was transferred by `release`, which left `active` untouched.
|
|
99
|
+
return this.lease(key, state, queuedMs);
|
|
100
|
+
}
|
|
101
|
+
/** In-flight requests currently holding a slot for `key`. */
|
|
102
|
+
activeFor(key) {
|
|
103
|
+
return this.keys.get(key)?.active ?? 0;
|
|
104
|
+
}
|
|
105
|
+
/** Requests currently waiting for a slot for `key`. */
|
|
106
|
+
queuedFor(key) {
|
|
107
|
+
return this.keys.get(key)?.queue.length ?? 0;
|
|
108
|
+
}
|
|
109
|
+
/** How many keys are being tracked — zero once every tenant goes idle. */
|
|
110
|
+
get trackedKeys() {
|
|
111
|
+
return this.keys.size;
|
|
112
|
+
}
|
|
113
|
+
lease(key, state, queuedMs) {
|
|
114
|
+
let released = false;
|
|
115
|
+
return {
|
|
116
|
+
granted: true,
|
|
117
|
+
queuedMs,
|
|
118
|
+
release: () => {
|
|
119
|
+
if (released)
|
|
120
|
+
return;
|
|
121
|
+
released = true;
|
|
122
|
+
const next = state.queue.shift();
|
|
123
|
+
if (next) {
|
|
124
|
+
// Hand the slot over instead of freeing it: `active` is unchanged
|
|
125
|
+
// and the woken request owns what this one held.
|
|
126
|
+
next.settle(true);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
state.active -= 1;
|
|
130
|
+
this.evictIfIdle(key, state);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Drop a key nobody is using, so the map is bounded by *live* tenants rather
|
|
136
|
+
* than by every tenant this replica has ever served.
|
|
137
|
+
*/
|
|
138
|
+
evictIfIdle(key, state) {
|
|
139
|
+
if (state.active <= 0 && state.queue.length === 0)
|
|
140
|
+
this.keys.delete(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.ConcurrencyLimiter = ConcurrencyLimiter;
|
|
144
|
+
// ─── Per-caller rate ────────────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Fixed-window request counter keyed by an arbitrary string.
|
|
147
|
+
*
|
|
148
|
+
* Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
|
|
149
|
+
* in constructive-db) rather than reinvented, so both edges refuse abuse the
|
|
150
|
+
* same way.
|
|
151
|
+
*/
|
|
152
|
+
class RateWindow {
|
|
153
|
+
windowMs;
|
|
154
|
+
maxKeys;
|
|
155
|
+
counts = new Map();
|
|
156
|
+
constructor(windowMs = 60_000, maxKeys = 10_000) {
|
|
157
|
+
this.windowMs = windowMs;
|
|
158
|
+
this.maxKeys = maxKeys;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Count one request against `key`; returns false once it is over `limit`.
|
|
162
|
+
*
|
|
163
|
+
* `limit` is per call rather than per instance because it comes from the
|
|
164
|
+
* tenant's settings, and one process serves many tenants.
|
|
165
|
+
*/
|
|
166
|
+
admit(key, limit, burst = 0) {
|
|
167
|
+
const ceiling = Math.floor(limit) + Math.max(Math.floor(burst), 0);
|
|
168
|
+
if (!Number.isFinite(ceiling) || ceiling <= 0)
|
|
169
|
+
return false;
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const entry = this.counts.get(key);
|
|
172
|
+
if (!entry || now - entry.windowStart >= this.windowMs) {
|
|
173
|
+
this.counts.set(key, { windowStart: now, count: 1 });
|
|
174
|
+
// Opportunistic sweep: drop stale windows so the map stays bounded.
|
|
175
|
+
if (this.counts.size > this.maxKeys) {
|
|
176
|
+
for (const [k, v] of this.counts) {
|
|
177
|
+
if (now - v.windowStart >= this.windowMs)
|
|
178
|
+
this.counts.delete(k);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
entry.count += 1;
|
|
184
|
+
return entry.count <= ceiling;
|
|
185
|
+
}
|
|
186
|
+
/** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
|
|
187
|
+
retryAfterMs(key) {
|
|
188
|
+
const entry = this.counts.get(key);
|
|
189
|
+
if (!entry)
|
|
190
|
+
return 0;
|
|
191
|
+
return Math.max(this.windowMs - (Date.now() - entry.windowStart), 0);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
exports.RateWindow = RateWindow;
|
|
195
|
+
/**
|
|
196
|
+
* The caller's address, read as far back through `X-Forwarded-For` as the
|
|
197
|
+
* deployment's own proxies reach — and no further.
|
|
198
|
+
*
|
|
199
|
+
* The header is caller-writable: whatever the client sends arrives at the left
|
|
200
|
+
* of the chain, and each proxy appends the peer it actually saw. So the
|
|
201
|
+
* rightmost entry is the one this deployment's edge observed and the leftmost
|
|
202
|
+
* is the one an abuser gets to choose. Taking the leftmost entry — the usual
|
|
203
|
+
* shape of this code — hands every attacker a fresh rate-limit key per request
|
|
204
|
+
* by sending a random `X-Forwarded-For`, which is worse than having no limiter
|
|
205
|
+
* at all, because it reads as one.
|
|
206
|
+
*
|
|
207
|
+
* `trustedProxyHops` is how many proxies of our own sit in front of the server:
|
|
208
|
+
* 0 means none (only the socket peer is believed, and the header is ignored
|
|
209
|
+
* outright), 1 means one ingress appended the client's address, and so on.
|
|
210
|
+
* Anything the chain cannot supply — an absent header, or a chain too short to
|
|
211
|
+
* have been written by all of them — falls back to the socket peer, which no
|
|
212
|
+
* caller can forge.
|
|
213
|
+
*/
|
|
214
|
+
const clientIpFrom = (req, trustedProxyHops) => {
|
|
215
|
+
const socketIp = req.socket?.remoteAddress ?? 'unknown';
|
|
216
|
+
if (trustedProxyHops <= 0)
|
|
217
|
+
return socketIp;
|
|
218
|
+
const header = req.headers['x-forwarded-for'];
|
|
219
|
+
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
220
|
+
if (!raw)
|
|
221
|
+
return socketIp;
|
|
222
|
+
const chain = raw
|
|
223
|
+
.split(',')
|
|
224
|
+
.map((entry) => entry.trim())
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
// A chain with fewer entries than we own proxies cannot have been written by
|
|
227
|
+
// all of them, so the request did not travel the path this number describes
|
|
228
|
+
// and nothing in the header is attributable. Reading its leftmost entry would
|
|
229
|
+
// return exactly the value a direct caller chose.
|
|
230
|
+
if (chain.length < trustedProxyHops)
|
|
231
|
+
return socketIp;
|
|
232
|
+
// Otherwise walk in from the right by the number of proxies we own: with one
|
|
233
|
+
// ingress a legitimate chain is `[client]` and a forged one is
|
|
234
|
+
// `[forged, client]`, and both resolve to the entry the ingress appended.
|
|
235
|
+
return chain[chain.length - trustedProxyHops];
|
|
236
|
+
};
|
|
237
|
+
exports.clientIpFrom = clientIpFrom;
|
|
238
|
+
/**
|
|
239
|
+
* How many proxies of our own sit in front of the server.
|
|
240
|
+
*
|
|
241
|
+
* Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
|
|
242
|
+
* an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
|
|
243
|
+
* a higher number here would let a direct caller pick its own limiter key.
|
|
244
|
+
*/
|
|
245
|
+
const trustedProxyHops = () => {
|
|
246
|
+
const raw = Number(process.env.TRUSTED_PROXY_HOPS ?? '');
|
|
247
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
248
|
+
};
|
|
249
|
+
exports.trustedProxyHops = trustedProxyHops;
|
package/esm/admission.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admission — the two in-process bounds that decide whether a request is
|
|
3
|
+
* allowed to start at all.
|
|
4
|
+
*
|
|
5
|
+
* `request-protection` resolves the numbers; this module is what spends them.
|
|
6
|
+
* The three GUCs it hands to PostgreSQL bound how *long* one request may run,
|
|
7
|
+
* which is a different question from how *many* may run at once: fifty legal
|
|
8
|
+
* 20-second queries saturate the pool that serves every other tenant on the
|
|
9
|
+
* replica without any single one of them misbehaving. So there are two bounds
|
|
10
|
+
* here, and they protect different things:
|
|
11
|
+
*
|
|
12
|
+
* - {@link ConcurrencyLimiter} bounds width per tenant. The resource being
|
|
13
|
+
* protected is *this process's* PostgreSQL pool, so the counter is
|
|
14
|
+
* deliberately in-process and `maxConcurrentRequests` is a per-replica
|
|
15
|
+
* budget. Coordinating it cluster-wide would need shared state on the hot
|
|
16
|
+
* path to bound something that is not shared.
|
|
17
|
+
*
|
|
18
|
+
* - {@link RateWindow} bounds one caller's rate. A tenant-wide rate limit
|
|
19
|
+
* cannot answer abuse on a public API: the tenant's own budget is what an
|
|
20
|
+
* anonymous flood spends, so exhausting it turns the tenant's site off on
|
|
21
|
+
* the attacker's behalf. The per-caller bound is therefore keyed on the
|
|
22
|
+
* only discriminator an anonymous request has, and it fails closed —
|
|
23
|
+
* unlike a billing quota, which serves and records overage.
|
|
24
|
+
*
|
|
25
|
+
* Neither touches the database. A limiter that needs a row lock to decide
|
|
26
|
+
* whether to admit a request has made the saturation it exists to prevent
|
|
27
|
+
* cheaper to cause.
|
|
28
|
+
*/
|
|
29
|
+
const REFUSED = (refusal, queuedMs) => ({
|
|
30
|
+
granted: false,
|
|
31
|
+
queuedMs,
|
|
32
|
+
refusal,
|
|
33
|
+
release: () => { }
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Per-key in-flight counter with a bounded wait queue.
|
|
37
|
+
*
|
|
38
|
+
* A released slot is handed straight to the longest-waiting request rather
|
|
39
|
+
* than decremented and re-acquired, so a queue drains in arrival order and a
|
|
40
|
+
* burst of new arrivals cannot starve what is already waiting.
|
|
41
|
+
*/
|
|
42
|
+
export class ConcurrencyLimiter {
|
|
43
|
+
keys = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* Take a slot for `key`, waiting up to `queueWaitMs` for one.
|
|
46
|
+
*
|
|
47
|
+
* Always resolves — a refusal is a value, not a throw, because the caller
|
|
48
|
+
* has to answer the client either way.
|
|
49
|
+
*/
|
|
50
|
+
async acquire(key, request) {
|
|
51
|
+
const limit = Math.floor(request.limit);
|
|
52
|
+
// A non-positive limit is a misconfiguration, not "unlimited": the bounds
|
|
53
|
+
// in request-protection have a floor of 1, so reaching here with 0 means
|
|
54
|
+
// something bypassed them. Refuse rather than silently admit everything.
|
|
55
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
56
|
+
return REFUSED('queue_full', 0);
|
|
57
|
+
const state = this.keys.get(key) ?? { active: 0, queue: [] };
|
|
58
|
+
if (!this.keys.has(key))
|
|
59
|
+
this.keys.set(key, state);
|
|
60
|
+
if (state.active < limit) {
|
|
61
|
+
state.active += 1;
|
|
62
|
+
return this.lease(key, state, 0);
|
|
63
|
+
}
|
|
64
|
+
const queueLimit = Math.max(Math.floor(request.queueLimit ?? limit), 0);
|
|
65
|
+
const queueWaitMs = Math.max(request.queueWaitMs, 0);
|
|
66
|
+
if (queueWaitMs === 0 || state.queue.length >= queueLimit) {
|
|
67
|
+
this.evictIfIdle(key, state);
|
|
68
|
+
return REFUSED('queue_full', 0);
|
|
69
|
+
}
|
|
70
|
+
const waitStarted = Date.now();
|
|
71
|
+
const granted = await new Promise((resolve) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
const waiter = {
|
|
74
|
+
settle: (value) => {
|
|
75
|
+
if (settled)
|
|
76
|
+
return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(waiter.timer);
|
|
79
|
+
resolve(value);
|
|
80
|
+
},
|
|
81
|
+
timer: setTimeout(() => {
|
|
82
|
+
const index = state.queue.indexOf(waiter);
|
|
83
|
+
if (index >= 0)
|
|
84
|
+
state.queue.splice(index, 1);
|
|
85
|
+
waiter.settle(false);
|
|
86
|
+
}, queueWaitMs)
|
|
87
|
+
};
|
|
88
|
+
state.queue.push(waiter);
|
|
89
|
+
});
|
|
90
|
+
const queuedMs = Date.now() - waitStarted;
|
|
91
|
+
if (!granted) {
|
|
92
|
+
this.evictIfIdle(key, state);
|
|
93
|
+
return REFUSED('queue_timeout', queuedMs);
|
|
94
|
+
}
|
|
95
|
+
// The slot was transferred by `release`, which left `active` untouched.
|
|
96
|
+
return this.lease(key, state, queuedMs);
|
|
97
|
+
}
|
|
98
|
+
/** In-flight requests currently holding a slot for `key`. */
|
|
99
|
+
activeFor(key) {
|
|
100
|
+
return this.keys.get(key)?.active ?? 0;
|
|
101
|
+
}
|
|
102
|
+
/** Requests currently waiting for a slot for `key`. */
|
|
103
|
+
queuedFor(key) {
|
|
104
|
+
return this.keys.get(key)?.queue.length ?? 0;
|
|
105
|
+
}
|
|
106
|
+
/** How many keys are being tracked — zero once every tenant goes idle. */
|
|
107
|
+
get trackedKeys() {
|
|
108
|
+
return this.keys.size;
|
|
109
|
+
}
|
|
110
|
+
lease(key, state, queuedMs) {
|
|
111
|
+
let released = false;
|
|
112
|
+
return {
|
|
113
|
+
granted: true,
|
|
114
|
+
queuedMs,
|
|
115
|
+
release: () => {
|
|
116
|
+
if (released)
|
|
117
|
+
return;
|
|
118
|
+
released = true;
|
|
119
|
+
const next = state.queue.shift();
|
|
120
|
+
if (next) {
|
|
121
|
+
// Hand the slot over instead of freeing it: `active` is unchanged
|
|
122
|
+
// and the woken request owns what this one held.
|
|
123
|
+
next.settle(true);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
state.active -= 1;
|
|
127
|
+
this.evictIfIdle(key, state);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Drop a key nobody is using, so the map is bounded by *live* tenants rather
|
|
133
|
+
* than by every tenant this replica has ever served.
|
|
134
|
+
*/
|
|
135
|
+
evictIfIdle(key, state) {
|
|
136
|
+
if (state.active <= 0 && state.queue.length === 0)
|
|
137
|
+
this.keys.delete(key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ─── Per-caller rate ────────────────────────────────────────────────────────
|
|
141
|
+
/**
|
|
142
|
+
* Fixed-window request counter keyed by an arbitrary string.
|
|
143
|
+
*
|
|
144
|
+
* Ported from the sync/static gateways (`compute/services/worker/src/rate-window.ts`
|
|
145
|
+
* in constructive-db) rather than reinvented, so both edges refuse abuse the
|
|
146
|
+
* same way.
|
|
147
|
+
*/
|
|
148
|
+
export class RateWindow {
|
|
149
|
+
windowMs;
|
|
150
|
+
maxKeys;
|
|
151
|
+
counts = new Map();
|
|
152
|
+
constructor(windowMs = 60_000, maxKeys = 10_000) {
|
|
153
|
+
this.windowMs = windowMs;
|
|
154
|
+
this.maxKeys = maxKeys;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Count one request against `key`; returns false once it is over `limit`.
|
|
158
|
+
*
|
|
159
|
+
* `limit` is per call rather than per instance because it comes from the
|
|
160
|
+
* tenant's settings, and one process serves many tenants.
|
|
161
|
+
*/
|
|
162
|
+
admit(key, limit, burst = 0) {
|
|
163
|
+
const ceiling = Math.floor(limit) + Math.max(Math.floor(burst), 0);
|
|
164
|
+
if (!Number.isFinite(ceiling) || ceiling <= 0)
|
|
165
|
+
return false;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const entry = this.counts.get(key);
|
|
168
|
+
if (!entry || now - entry.windowStart >= this.windowMs) {
|
|
169
|
+
this.counts.set(key, { windowStart: now, count: 1 });
|
|
170
|
+
// Opportunistic sweep: drop stale windows so the map stays bounded.
|
|
171
|
+
if (this.counts.size > this.maxKeys) {
|
|
172
|
+
for (const [k, v] of this.counts) {
|
|
173
|
+
if (now - v.windowStart >= this.windowMs)
|
|
174
|
+
this.counts.delete(k);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
entry.count += 1;
|
|
180
|
+
return entry.count <= ceiling;
|
|
181
|
+
}
|
|
182
|
+
/** Milliseconds until `key`'s window rolls over — the `Retry-After` hint. */
|
|
183
|
+
retryAfterMs(key) {
|
|
184
|
+
const entry = this.counts.get(key);
|
|
185
|
+
if (!entry)
|
|
186
|
+
return 0;
|
|
187
|
+
return Math.max(this.windowMs - (Date.now() - entry.windowStart), 0);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The caller's address, read as far back through `X-Forwarded-For` as the
|
|
192
|
+
* deployment's own proxies reach — and no further.
|
|
193
|
+
*
|
|
194
|
+
* The header is caller-writable: whatever the client sends arrives at the left
|
|
195
|
+
* of the chain, and each proxy appends the peer it actually saw. So the
|
|
196
|
+
* rightmost entry is the one this deployment's edge observed and the leftmost
|
|
197
|
+
* is the one an abuser gets to choose. Taking the leftmost entry — the usual
|
|
198
|
+
* shape of this code — hands every attacker a fresh rate-limit key per request
|
|
199
|
+
* by sending a random `X-Forwarded-For`, which is worse than having no limiter
|
|
200
|
+
* at all, because it reads as one.
|
|
201
|
+
*
|
|
202
|
+
* `trustedProxyHops` is how many proxies of our own sit in front of the server:
|
|
203
|
+
* 0 means none (only the socket peer is believed, and the header is ignored
|
|
204
|
+
* outright), 1 means one ingress appended the client's address, and so on.
|
|
205
|
+
* Anything the chain cannot supply — an absent header, or a chain too short to
|
|
206
|
+
* have been written by all of them — falls back to the socket peer, which no
|
|
207
|
+
* caller can forge.
|
|
208
|
+
*/
|
|
209
|
+
export const clientIpFrom = (req, trustedProxyHops) => {
|
|
210
|
+
const socketIp = req.socket?.remoteAddress ?? 'unknown';
|
|
211
|
+
if (trustedProxyHops <= 0)
|
|
212
|
+
return socketIp;
|
|
213
|
+
const header = req.headers['x-forwarded-for'];
|
|
214
|
+
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
215
|
+
if (!raw)
|
|
216
|
+
return socketIp;
|
|
217
|
+
const chain = raw
|
|
218
|
+
.split(',')
|
|
219
|
+
.map((entry) => entry.trim())
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
// A chain with fewer entries than we own proxies cannot have been written by
|
|
222
|
+
// all of them, so the request did not travel the path this number describes
|
|
223
|
+
// and nothing in the header is attributable. Reading its leftmost entry would
|
|
224
|
+
// return exactly the value a direct caller chose.
|
|
225
|
+
if (chain.length < trustedProxyHops)
|
|
226
|
+
return socketIp;
|
|
227
|
+
// Otherwise walk in from the right by the number of proxies we own: with one
|
|
228
|
+
// ingress a legitimate chain is `[client]` and a forged one is
|
|
229
|
+
// `[forged, client]`, and both resolve to the entry the ingress appended.
|
|
230
|
+
return chain[chain.length - trustedProxyHops];
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* How many proxies of our own sit in front of the server.
|
|
234
|
+
*
|
|
235
|
+
* Defaults to 0 — the socket peer, which cannot be forged. A deployment behind
|
|
236
|
+
* an ingress sets `TRUSTED_PROXY_HOPS` to the number of hops it owns; guessing
|
|
237
|
+
* a higher number here would let a direct caller pick its own limiter key.
|
|
238
|
+
*/
|
|
239
|
+
export const trustedProxyHops = () => {
|
|
240
|
+
const raw = Number(process.env.TRUSTED_PROXY_HOPS ?? '');
|
|
241
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
242
|
+
};
|
package/esm/index.js
CHANGED
|
@@ -38,9 +38,13 @@ export { createBillingClient } from './billing-client';
|
|
|
38
38
|
export { buildPgSettings } from './pg-settings';
|
|
39
39
|
// withPgClient helper
|
|
40
40
|
export { withPgClient } from './pg-client';
|
|
41
|
+
export { ASSUMED_PAGE_SIZE, DEFAULT_REQUEST_PROTECTION, INTROSPECTION_BOUND, PROTECTION_BOUNDS, protectionPgSettings, resolveRequestProtection, } from './request-protection';
|
|
42
|
+
export { clientIpFrom, ConcurrencyLimiter, RateWindow, trustedProxyHops } from './admission';
|
|
43
|
+
export { createRecordRefusalsSink, DEFAULT_REFUSAL_FLUSH_INTERVAL_MS, DEFAULT_REFUSAL_FLUSH_JITTER_MS, DEFAULT_REFUSAL_MAX_KEYS, OVERFLOW_ROUTE, OVERFLOW_SOURCE, REFUSAL_REASONS, refusalKeyOf, refusalOverflowKey, RefusalRecorder, refusalRows, sourceBucket, UNKNOWN_SOURCE } from './refusals';
|
|
44
|
+
export { BoundedCounter, CounterFlusher } from './usage-counter';
|
|
41
45
|
// Request ID middleware
|
|
42
46
|
export { requestIdMiddleware } from './request-id';
|
|
43
47
|
export { buildContext, createContextMiddleware } from './context';
|
|
44
|
-
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
48
|
+
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
45
49
|
// Side-effect: Express type augmentation
|
|
46
50
|
import './types';
|
package/esm/loaders/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - rlsModule (routing-plane rls_settings)
|
|
10
10
|
* - corsOrigins (routing-plane cors_settings)
|
|
11
11
|
* - databaseSettings(routing-plane database_settings)
|
|
12
|
+
* - requestProtection (routing-plane database_settings/api_settings bounds)
|
|
12
13
|
* - pubkeyChallengeSettings (routing-plane pubkey_settings)
|
|
13
14
|
* - webauthnSettings(routing-plane webauthn_settings)
|
|
14
15
|
* - authSettings (metaschema_modules_public.sessions_module → tenant DB)
|
|
@@ -44,6 +45,7 @@ export { identityProvidersLoader, requireIdentityProvider } from './identity-pro
|
|
|
44
45
|
export { inferenceLogLoader } from './inference-log';
|
|
45
46
|
export { llmLoader } from './llm';
|
|
46
47
|
export { pubkeyLoader } from './pubkey';
|
|
48
|
+
export { requestProtectionLoader } from './request-protection';
|
|
47
49
|
export { rlsLoader } from './rls';
|
|
48
50
|
export { webauthnLoader } from './webauthn';
|
|
49
51
|
/**
|
|
@@ -60,6 +62,7 @@ import { inferenceLogLoader } from './inference-log';
|
|
|
60
62
|
import { llmLoader } from './llm';
|
|
61
63
|
import { pubkeyLoader } from './pubkey';
|
|
62
64
|
import { createLoaderRegistry } from './registry';
|
|
65
|
+
import { requestProtectionLoader } from './request-protection';
|
|
63
66
|
import { rlsLoader } from './rls';
|
|
64
67
|
import { webauthnLoader } from './webauthn';
|
|
65
68
|
export function createDefaultRegistry() {
|
|
@@ -76,5 +79,6 @@ export function createDefaultRegistry() {
|
|
|
76
79
|
registry.register(agentChatLoader);
|
|
77
80
|
registry.register(llmLoader);
|
|
78
81
|
registry.register(computeLoader);
|
|
82
|
+
registry.register(requestProtectionLoader);
|
|
79
83
|
return registry;
|
|
80
84
|
}
|