@daloyjs/core 1.0.0-rc.6 → 1.0.0-rc.8

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/dist/conn-info.js CHANGED
@@ -106,6 +106,100 @@ export function pickForwardedForByHops(header, hops) {
106
106
  // typically lives at parts[parts.length - hops].
107
107
  return parts[parts.length - hops];
108
108
  }
109
+ /**
110
+ * Validate a middleware's forwarded-header trust options and resolve them into
111
+ * a single hop count. Every middleware that keys on client IP calls this once
112
+ * at construction, so the trust policy lives in exactly one place instead of
113
+ * being re-derived at each call site.
114
+ *
115
+ * That single-source property is the point: the spoofable-IP vulnerability this
116
+ * module now guards against existed in nine independent copies of the same
117
+ * leftmost-`X-Forwarded-For` read, which meant nine separate places to get it
118
+ * wrong and nine separate fixes. Keeping the decision here means a future
119
+ * change to the trust rules lands everywhere at once.
120
+ *
121
+ * `trustedHops` must be an integer in [1, 64], mirroring the
122
+ * `behindProxy.hops` range: the floor of one exists because a middleware that
123
+ * trusts zero proxy hops has no business reading forwarding headers at all.
124
+ *
125
+ * @param name - Middleware function name used in error messages.
126
+ * @param opts - The middleware's options object; only the two trust fields are
127
+ * read, so any middleware option type is structurally acceptable.
128
+ * @returns The number of trusted proxy hops when forwarded-header trust is
129
+ * enabled — `trustedHops` verbatim, or `1` for a bare
130
+ * `trustProxyHeaders: true` — or `undefined` when trust is off and the caller
131
+ * must not read forwarding headers at all.
132
+ * @throws Error when `trustedHops` is not an integer in [1, 64], or when
133
+ * `trustProxyHeaders: false` is combined with a `trustedHops` value. That
134
+ * pairing is a contradiction, and it previously resolved silently in favour
135
+ * of trust — meaning an explicit opt-out was ignored.
136
+ * @internal
137
+ */
138
+ export function resolveForwardedTrust(name, opts) {
139
+ const hops = opts.trustedHops;
140
+ const trust = opts.trustProxyHeaders;
141
+ if (hops !== undefined) {
142
+ if (!Number.isInteger(hops) || hops < 1 || hops > 64) {
143
+ throw new Error(`${name}: trustedHops must be an integer in [1, 64]; got ${String(hops)}.`);
144
+ }
145
+ if (trust === false) {
146
+ throw new Error(`${name}: trustProxyHeaders: false contradicts trustedHops: ${hops}. ` +
147
+ "trustedHops implies proxy-header trust; drop whichever one you did not mean.");
148
+ }
149
+ return hops;
150
+ }
151
+ return trust === true ? 1 : undefined;
152
+ }
153
+ /**
154
+ * Resolve the client IP from the proxy-set forwarding headers, walking a
155
+ * declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
156
+ *
157
+ * The right side is the spoof-resistant side: each proxy in the chain appends
158
+ * the address of the peer it actually observed, so the last `hops` entries
159
+ * were written by infrastructure you control, while anything further left is
160
+ * attacker-influenceable. Reading the leftmost entry (the historic
161
+ * `split(",")[0]` pattern) trusts the MOST attacker-controllable slot and
162
+ * enabled both rate-limit/ban evasion (rotate a spoofed left entry) and
163
+ * victim-IP framing (spoof a victim's address to get them banned or blocked).
164
+ *
165
+ * Falls back to `X-Real-IP` **only for a single declared hop**. That header
166
+ * carries exactly one hop of information, so it can stand in for a one-proxy
167
+ * declaration (the common nginx `X-Real-IP`-only setup) but cannot possibly
168
+ * satisfy a two-or-more-hop one. With 2+ declared hops and a chain shorter
169
+ * than the declaration, this returns `undefined` rather than guessing.
170
+ *
171
+ * Security note: this resolver is only meaningful when every request reaches
172
+ * the app through a proxy chain you control that appends (or overwrites)
173
+ * these headers. With no proxy in front, any forwarded-header trust is
174
+ * attacker-controlled by definition.
175
+ *
176
+ * @param request - Incoming request whose forwarding headers are read.
177
+ * @param hops - Number of trusted proxy hops; `1` (default) reads the
178
+ * rightmost entry — the one your immediate proxy appended.
179
+ * @returns The resolved client IP, or `undefined` when no forwarded identity
180
+ * is available. Callers decide their own posture for `undefined`
181
+ * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
182
+ * @since 1.0.0-rc.7
183
+ */
184
+ export function resolveForwardedClientIp(request, hops = 1) {
185
+ const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
186
+ if (picked)
187
+ return picked;
188
+ // Fail closed past one hop. A chain that produced fewer than `hops` entries
189
+ // means the request never traversed the declared topology — a direct-to-origin
190
+ // request that skipped the CDN, say — so no forwarded value it carries is
191
+ // trustworthy, `X-Real-IP` least of all. Trusting it here would hand back the
192
+ // rotating-identity evasion and victim-IP framing that reading from the right
193
+ // exists to prevent.
194
+ //
195
+ // An attacker inside the chain cannot reach this path: conforming proxies
196
+ // append, so prepending entries only ever lengthens the header. Reaching it
197
+ // requires bypassing the declared chain, and the safe answer there is "no
198
+ // identity", not "the identity the caller asked me to believe".
199
+ if (hops !== 1)
200
+ return undefined;
201
+ return request.headers.get("x-real-ip") ?? undefined;
202
+ }
109
203
  /**
110
204
  * Resolve the client IP for this request using the configured
111
205
  * {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
@@ -125,10 +219,11 @@ export function resolveClientIp(request, cfg) {
125
219
  return peer;
126
220
  if (cfg === "loopback") {
127
221
  if (peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1") {
128
- const xff = request.headers.get("x-forwarded-for");
129
- const first = xff?.split(",")[0]?.trim();
130
- if (first)
131
- return first;
222
+ // The same-host proxy is the single trusted hop, so read the slot IT
223
+ // appended (rightmost), never the attacker-influenceable leftmost one.
224
+ const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), 1);
225
+ if (picked)
226
+ return picked;
132
227
  }
133
228
  return peer;
134
229
  }
@@ -24,7 +24,7 @@
24
24
  * @module
25
25
  * @since 0.37.0
26
26
  */
27
- import type { BaseContext, Hooks } from "./types.js";
27
+ import type { Hooks, IdentityGateContext } from "./types.js";
28
28
  /**
29
29
  * Why a request was (or would have been) blocked by {@link geoBlock}.
30
30
  *
@@ -57,7 +57,7 @@ export interface GeoBlockDecision {
57
57
  *
58
58
  * @since 0.37.0
59
59
  */
60
- export type CountryFromContext = (ctx: BaseContext<any, any>) => string | undefined | null | Promise<string | undefined | null>;
60
+ export type CountryFromContext = (ctx: IdentityGateContext) => string | undefined | null | Promise<string | undefined | null>;
61
61
  /**
62
62
  * Operator-supplied IP → country lookup (e.g. a MaxMind reader). Return
63
63
  * `undefined`/`null`/`""` when the IP cannot be mapped to a country.
@@ -110,14 +110,29 @@ export interface GeoBlockOptions {
110
110
  * default Daloy fails closed because Web-standard `Request` objects do not
111
111
  * expose the peer address. Ignored when `resolveCountry` is used.
112
112
  */
113
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
113
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
114
114
  /**
115
115
  * Read `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Defaults
116
116
  * to `false` because those headers are client-spoofable unless every
117
117
  * request reaches Daloy through a proxy chain you control. Ignored when
118
118
  * `resolveCountry` is used or a custom `resolveIp` is supplied.
119
+ *
120
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
121
+ * entry — the one your immediate proxy appended — never the
122
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
123
+ * {@link trustedHops} instead.
119
124
  */
120
125
  trustProxyHeaders?: boolean;
126
+ /**
127
+ * Declare exactly how many proxy hops sit between Daloy and the public
128
+ * internet. Implies proxy-header trust and reads the client IP that many
129
+ * entries from the right of `X-Forwarded-For` via
130
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
131
+ * entries on the left cannot spoof an allowed-country IP. Must be an
132
+ * integer in [1, 64]; validated at construction. Ignored when
133
+ * `resolveCountry` is used or a custom `resolveIp` is supplied.
134
+ */
135
+ trustedHops?: number;
121
136
  /**
122
137
  * What to do when the country cannot be resolved. Defaults to `false` when
123
138
  * an `allow` list is configured (fail closed — an unknown country is not on
package/dist/geo-block.js CHANGED
@@ -25,6 +25,7 @@
25
25
  * @since 0.37.0
26
26
  */
27
27
  import { ForbiddenError } from "./errors.js";
28
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
28
29
  /** @internal Validate + normalise a configured country code, or throw. */
29
30
  function normalizeConfiguredCode(input) {
30
31
  const code = input.trim().toUpperCase();
@@ -38,16 +39,14 @@ function normalizeConfiguredCode(input) {
38
39
  function noIpResolver(_ctx) {
39
40
  return undefined;
40
41
  }
41
- /** @internal Read the leading `X-Forwarded-For` / `X-Real-IP` hop. */
42
- function forwardedIpResolver(ctx) {
43
- const headers = ctx.request.headers;
44
- const forwarded = headers.get("x-forwarded-for");
45
- if (forwarded) {
46
- const first = forwarded.split(",")[0]?.trim();
47
- if (first)
48
- return first;
49
- }
50
- return ctx.request.headers.get("x-real-ip") ?? undefined;
42
+ /**
43
+ * @internal Read the client IP `hops` entries from the right of
44
+ * `X-Forwarded-For` (falling back to `X-Real-IP`). The right side is written
45
+ * by the operator's own proxy chain and is therefore the spoof-resistant
46
+ * side — see {@link resolveForwardedClientIp}.
47
+ */
48
+ function forwardedIpResolver(hops) {
49
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
51
50
  }
52
51
  /**
53
52
  * Block or allow requests by client country. Daloy ships no GeoIP database;
@@ -105,9 +104,16 @@ export function geoBlock(opts) {
105
104
  const onBlock = opts.onBlock;
106
105
  const lookupCountry = opts.lookupCountry;
107
106
  const resolveCountry = opts.resolveCountry;
108
- const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
107
+ const hops = resolveForwardedTrust("geoBlock()", opts);
108
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
109
109
  return {
110
- async beforeHandle(ctx) {
110
+ // Runs in `preBody`, not `beforeHandle`. A `beforeHandle` hook that returns
111
+ // a Response ends the chain, so a country gate in that phase is preempted by
112
+ // any earlier `beforeHandle` middleware that short-circuits — a
113
+ // `responseCache()` HIT above it would serve a denied country the cached
114
+ // body. `preBody` always precedes `beforeHandle`, which makes the gate
115
+ // immune to mount order. See {@link geoBlock}'s security note.
116
+ async preBody(ctx) {
111
117
  let ip;
112
118
  let rawCountry;
113
119
  if (resolveCountry) {
@@ -164,17 +164,71 @@ export interface IdempotencyOptions {
164
164
  * unauthenticated idempotent writes). Returning a stable per-user id is
165
165
  * preferable to the raw credential when tokens rotate between retries.
166
166
  *
167
+ * **Supply this whenever `Authorization` is not per-user.** The default assumes
168
+ * that header names one caller. If it is shared — a per-tenant API key, a
169
+ * service token, a gateway credential — with end users distinguished some other
170
+ * way (a session cookie, a subject claim your app reads), then the default
171
+ * partitions per *tenant* and every user inside one tenant shares a namespace.
172
+ * A caller who knows another's `Idempotency-Key` can then replay their stored
173
+ * response. This is not detectable from the header alone, which is why it is
174
+ * your call rather than a framework guard: a resolvable-but-coarse scope looks
175
+ * identical to a correctly per-user one. The cookie-only case *is* guarded —
176
+ * see {@link allowUnscopedCallers}.
177
+ *
178
+ * Independently of scoping, a replay never re-issues `Set-Cookie`, so a coarse
179
+ * namespace cannot escalate from disclosing a response body into handing over a
180
+ * live session.
181
+ *
167
182
  * @since 0.40.0
168
183
  */
169
184
  scope?: (ctx: BaseContext<any, any>) => string | undefined | Promise<string | undefined>;
185
+ /**
186
+ * Accept callers the default {@link scope} resolver cannot identify, letting
187
+ * them share one idempotency namespace. Default `false`.
188
+ *
189
+ * The guard this disables exists because a cookie-authenticated request
190
+ * carries no `Authorization` header, so the default resolver returns
191
+ * `undefined` and the namespace collapses. The retry fingerprint (method +
192
+ * path + body) is then all that separates two users — and two users
193
+ * submitting the same payload fingerprint identically, so one replays the
194
+ * other's stored response (CWE-524). Rather than share the namespace
195
+ * silently, a cookie-bearing request with no resolvable scope throws with an
196
+ * actionable message.
197
+ *
198
+ * Set this to `true` only when unscoped callers are genuinely interchangeable
199
+ * — a public, unauthenticated idempotent write where no response body is
200
+ * caller-specific. Supplying {@link scope} is almost always the right answer
201
+ * instead. A custom `scope` bypasses the guard entirely, including when it
202
+ * returns `undefined`, because an explicit resolver owns its own posture.
203
+ *
204
+ * @since 1.0.0-rc.8
205
+ */
206
+ allowUnscopedCallers?: boolean;
170
207
  }
171
208
  /**
172
209
  * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
173
- * deployments. Expired records are dropped on access; the map is opportunistically
174
- * pruned so it cannot grow without bound.
210
+ * deployments. Expired records are dropped on access.
211
+ *
212
+ * Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
213
+ * store is still at the cap the oldest surviving record is evicted. The sweep
214
+ * alone is not a bound — it only drops records that have *expired*, so a stream
215
+ * of unique keys inside the TTL grew the map linearly no matter how often it ran,
216
+ * with each entry pinning a stored response body.
217
+ *
218
+ * Evicting a live record can only cost exactly-once semantics for a retry that
219
+ * arrives after the eviction — it re-executes rather than replaying. That is the
220
+ * right trade against unbounded memory, but it is a reason to supply a shared
221
+ * (e.g. Redis) store for any deployment where the key volume approaches the cap.
175
222
  */
176
223
  export declare class MemoryIdempotencyStore implements IdempotencyStore {
177
224
  private readonly map;
225
+ private readonly maxEntries;
226
+ /**
227
+ * @param maxEntries - Maximum live records retained. Must be a positive
228
+ * integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
229
+ * @throws Error when `maxEntries` is not a positive integer.
230
+ */
231
+ constructor(maxEntries?: number);
178
232
  /**
179
233
  * @inheritDoc
180
234
  * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
@@ -55,13 +55,44 @@ export function _resetSharedIdempotencyStoresForTests() {
55
55
  SHARED_IDEMPOTENCY_STORES.clear();
56
56
  }
57
57
  // ---------- Default store ----------
58
+ /**
59
+ * Default cap on live records held by {@link MemoryIdempotencyStore}.
60
+ *
61
+ * Each record can hold a base64 response body up to
62
+ * {@link IdempotencyOptions.maxResponseBytes} (1 MiB by default), so the cap is
63
+ * what actually bounds this store's footprint. Matches the size at which the
64
+ * store already attempted an expiry sweep.
65
+ */
66
+ const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
58
67
  /**
59
68
  * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
60
- * deployments. Expired records are dropped on access; the map is opportunistically
61
- * pruned so it cannot grow without bound.
69
+ * deployments. Expired records are dropped on access.
70
+ *
71
+ * Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
72
+ * store is still at the cap the oldest surviving record is evicted. The sweep
73
+ * alone is not a bound — it only drops records that have *expired*, so a stream
74
+ * of unique keys inside the TTL grew the map linearly no matter how often it ran,
75
+ * with each entry pinning a stored response body.
76
+ *
77
+ * Evicting a live record can only cost exactly-once semantics for a retry that
78
+ * arrives after the eviction — it re-executes rather than replaying. That is the
79
+ * right trade against unbounded memory, but it is a reason to supply a shared
80
+ * (e.g. Redis) store for any deployment where the key volume approaches the cap.
62
81
  */
63
82
  export class MemoryIdempotencyStore {
64
83
  map = new Map();
84
+ maxEntries;
85
+ /**
86
+ * @param maxEntries - Maximum live records retained. Must be a positive
87
+ * integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
88
+ * @throws Error when `maxEntries` is not a positive integer.
89
+ */
90
+ constructor(maxEntries = DEFAULT_MAX_IDEMPOTENCY_ENTRIES) {
91
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
92
+ throw new Error(`MemoryIdempotencyStore: maxEntries must be a positive integer; got ${String(maxEntries)}.`);
93
+ }
94
+ this.maxEntries = maxEntries;
95
+ }
65
96
  /**
66
97
  * @inheritDoc
67
98
  * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
@@ -71,9 +102,19 @@ export class MemoryIdempotencyStore {
71
102
  const existing = this.read(key);
72
103
  if (existing)
73
104
  return existing;
74
- this.map.set(key, record);
75
- if (this.map.size > 10_000)
105
+ if (this.map.size >= this.maxEntries) {
76
106
  this.prune();
107
+ // Still full: every record is live, so drop the oldest. `Map` iterates in
108
+ // insertion order, and `complete()` overwrites in place rather than
109
+ // re-inserting, so the first key is the least recently reserved.
110
+ while (this.map.size >= this.maxEntries) {
111
+ const oldest = this.map.keys().next();
112
+ if (oldest.done)
113
+ break;
114
+ this.map.delete(oldest.value);
115
+ }
116
+ }
117
+ this.map.set(key, record);
77
118
  return null;
78
119
  }
79
120
  /** @inheritDoc */
@@ -185,20 +226,69 @@ function validateKey(key, headerName, maxLen) {
185
226
  throw new BadRequestError(`${headerName} header contains invalid characters.`);
186
227
  }
187
228
  }
229
+ /**
230
+ * Response headers that must never be stored for replay.
231
+ *
232
+ * `set-cookie` is the security-critical entry. A stored response was replayed
233
+ * with every header the original produced, so a `Set-Cookie` issued to the first
234
+ * caller was re-issued to whoever replayed the record. Combined with any
235
+ * coarse-scoped namespace (a shared tenant `Authorization`, or an explicit
236
+ * `allowUnscopedCallers: true`) that promotes a body disclosure into handing over
237
+ * a live session — account takeover rather than data leakage. Even for a
238
+ * correctly scoped, genuinely same-caller retry it is wrong: the replay would
239
+ * resurrect a cookie the handler set once, silently rolling back a session
240
+ * rotation performed at login or on a privilege change.
241
+ *
242
+ * Unlike `responseCache()`, which refuses to store a `Set-Cookie` response at
243
+ * all, idempotency strips and stores: declining to store would release the
244
+ * reservation and let a retry re-execute the handler, which for a payment or
245
+ * order endpoint is the double-charge this middleware exists to prevent.
246
+ * Stripping keeps exactly-once semantics and drops only the header that must not
247
+ * be replayed.
248
+ *
249
+ * The remainder are hop-by-hop or per-request fields (RFC 9110 §7.6.1) that
250
+ * describe the one connection or the one request that populated the record;
251
+ * replaying `x-request-id` in particular hands every later caller a correlation
252
+ * id belonging to someone else's request.
253
+ */
254
+ const NEVER_REPLAYED_HEADERS = new Set([
255
+ "set-cookie",
256
+ "set-cookie2",
257
+ "age",
258
+ "connection",
259
+ "keep-alive",
260
+ "proxy-authenticate",
261
+ "proxy-authorization",
262
+ "te",
263
+ "trailer",
264
+ "transfer-encoding",
265
+ "upgrade",
266
+ "x-request-id",
267
+ ]);
188
268
  async function captureResponse(res, maxBytes) {
189
269
  const buf = new Uint8Array(await res.clone().arrayBuffer());
190
270
  if (buf.byteLength > maxBytes)
191
271
  return null;
192
272
  const headers = [];
193
273
  res.headers.forEach((value, name) => {
194
- headers.push([name, value]);
274
+ // Filter on capture, not on replay, so a credential never reaches the store
275
+ // in the first place — a shared (Redis) store would otherwise persist one
276
+ // caller's session cookie for the whole TTL.
277
+ if (!NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
278
+ headers.push([name, value]);
195
279
  });
196
280
  return { status: res.status, headers, body: buf.byteLength ? bytesToBase64(buf) : "" };
197
281
  }
198
282
  function buildReplayResponse(stored, replayHeaderName) {
199
283
  const headers = new Headers();
200
- for (const [name, value] of stored.headers)
284
+ for (const [name, value] of stored.headers) {
285
+ // Filtered on capture already; re-checked here so a record written by an
286
+ // older build — or by any other writer sharing the same Redis store — cannot
287
+ // replay a credential either.
288
+ if (NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
289
+ continue;
201
290
  headers.set(name, value);
291
+ }
202
292
  headers.set(replayHeaderName, "true");
203
293
  const body = stored.body ? base64ToBytes(stored.body) : null;
204
294
  return markSchemaValidatedResponse(new Response(body, { status: stored.status, headers }));
@@ -252,6 +342,7 @@ export function idempotency(opts = {}) {
252
342
  const replayHeaderName = (opts.replayHeaderName ?? "idempotency-replayed").toLowerCase();
253
343
  const methods = new Set((opts.methods ?? ["POST", "PUT", "PATCH", "DELETE"]).map((m) => m.toUpperCase()));
254
344
  const requireKey = opts.requireKey === true;
345
+ const allowUnscopedCallers = opts.allowUnscopedCallers === true;
255
346
  const cacheableStatus = opts.cacheableStatus ?? ((status) => status < 500);
256
347
  const ttlMs = ttlSeconds * 1_000;
257
348
  let store;
@@ -291,6 +382,44 @@ export function idempotency(opts = {}) {
291
382
  const scopeRaw = opts.scope
292
383
  ? await opts.scope(ctx)
293
384
  : (ctx.request.headers.get("authorization") ?? undefined);
385
+ // A credentialed request the default resolver cannot see is the dangerous
386
+ // case: cookie-session auth sends no `Authorization`, so `scopeRaw` is
387
+ // undefined, the namespace collapses to the shared one, and the retry
388
+ // fingerprint (method + path + body) becomes the only thing separating two
389
+ // users. Two users legitimately submit the same payload fingerprint
390
+ // identically — so client B replays client A's stored response, which is
391
+ // exactly the CWE-524 disclosure `scope` exists to prevent.
392
+ //
393
+ // Fail loudly rather than share the namespace. This mirrors
394
+ // `responseCache()`, which treats `Cookie` as a credential alongside
395
+ // `Authorization` for the same reason. Only fires when a cookie is present
396
+ // *and* nothing resolved, so the documented bearer-token path is untouched
397
+ // and a genuinely anonymous caller still shares the unscoped namespace.
398
+ //
399
+ // Deliberately NOT widened to "any cookie-bearing request". A resolvable
400
+ // scope can still be too coarse — a per-tenant API key with
401
+ // cookie-identified end users partitions per tenant while every user inside
402
+ // one shares a namespace — but that is indistinguishable from the far more
403
+ // common shape of a *per-user* bearer token arriving alongside incidental
404
+ // browser cookies (analytics, consent, CSRF), where the default is already
405
+ // correct. Keying the guard on the cookie's presence rejects that setup with
406
+ // a 500, so the check stays where the default is provably useless rather
407
+ // than merely possibly coarse. Callers whose `Authorization` is shared
408
+ // across users must pass `scope` — see the TSDoc on
409
+ // {@link IdempotencyOptions.scope}. Independently of scoping, `Set-Cookie`
410
+ // is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
411
+ // coarse namespace cannot escalate into handing over a live session.
412
+ if (!opts.scope &&
413
+ scopeRaw === undefined &&
414
+ !allowUnscopedCallers &&
415
+ ctx.request.headers.has("cookie")) {
416
+ throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
417
+ "The default scope reads the Authorization header, which this request does not carry, " +
418
+ "so every cookie-authenticated caller would share one idempotency namespace and could " +
419
+ "replay another caller's stored response (CWE-524). Pass " +
420
+ "`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
421
+ "`allowUnscopedCallers: true` if these callers are genuinely interchangeable.");
422
+ }
294
423
  const scopeTag = scopeRaw ? `${await sha256Hex(scopeRaw)}:` : "";
295
424
  const storeKey = `${keyPrefix}${scopeTag}${key}`;
296
425
  const now = Date.now();
package/dist/index.d.ts CHANGED
@@ -6,21 +6,21 @@ export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
7
  export { _resetIndeterminateEnvWarningForTests } from "./app.js";
8
8
  export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, AsyncAPIRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
9
- export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
9
+ export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
10
10
  export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
11
11
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
12
12
  export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
13
13
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
14
14
  export type { DependencyHooks, DependencyOptions } from "./dependency.js";
15
- export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
15
+ export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, IdentityGateContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
16
16
  export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
17
17
  export type { ProblemDetails, ProblemRenderOptions, HttpErrorOptions } from "./errors.js";
18
18
  export type { StandardSchemaV1 } from "./schema.js";
19
19
  export { validate, isStandardSchema } from "./schema.js";
20
20
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
21
21
  export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult } from "./openapi-diff.js";
22
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
23
- export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
22
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
23
+ export type { McpCacheHints, McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpImplementation, McpInputRequest, McpInputRequests, McpInputRequiredResult, McpInputResponses, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpProtocolEra, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
24
24
  export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
25
25
  export type { WebhookHmacAlgorithm } from "./security.js";
26
26
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
@@ -101,5 +101,5 @@ export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACI
101
101
  export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
102
102
  export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
103
103
  export type { TenancyOptions, TenantResolver, TenantScopeOptions, SubdomainTenantOptions, PathPrefixTenantOptions, ClaimTenantOptions, UnresolvedStatus, InvalidStatus, } from "./tenancy.js";
104
- export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
104
+ export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
105
105
  export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
package/dist/index.js CHANGED
@@ -5,13 +5,13 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
5
5
  export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
7
  export { _resetIndeterminateEnvWarningForTests } from "./app.js";
8
- export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
8
+ export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
9
9
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
10
10
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
11
11
  export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
12
12
  export { validate, isStandardSchema } from "./schema.js";
13
13
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
14
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
14
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
15
15
  export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
16
16
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
17
17
  export { etag } from "./etag.js";
@@ -51,4 +51,4 @@ export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATI
51
51
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
52
52
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
53
53
  export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
54
- export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
54
+ export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
@@ -44,7 +44,7 @@
44
44
  * @module
45
45
  * @since 0.37.0
46
46
  */
47
- import type { BaseContext, Hooks } from "./types.js";
47
+ import type { Hooks, IdentityGateContext } from "./types.js";
48
48
  /**
49
49
  * A pluggable source of abusive IP / CIDR entries. Implementations return the
50
50
  * raw list each refresh; parsing, validation, and de-duplication are handled by
@@ -105,12 +105,26 @@ export interface IpReputationOptions {
105
105
  * Custom client-IP resolver. Overrides {@link IpReputationOptions.trustProxyHeaders}.
106
106
  * Defaults to failing open (no IP → not blocked).
107
107
  */
108
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
108
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
109
109
  /**
110
110
  * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
111
111
  * enable behind a trusted proxy that overwrites these headers.
112
+ *
113
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
114
+ * entry — the one your immediate proxy appended — never the
115
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
116
+ * {@link trustedHops} instead.
112
117
  */
113
118
  trustProxyHeaders?: boolean;
119
+ /**
120
+ * Declare exactly how many proxy hops sit between Daloy and the public
121
+ * internet. Implies proxy-header trust and reads the client IP that many
122
+ * entries from the right of `X-Forwarded-For` via
123
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
124
+ * entries on the left cannot dodge the denylist. Must be an integer in
125
+ * [1, 64]; validated at construction.
126
+ */
127
+ trustedHops?: number;
114
128
  /**
115
129
  * `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
116
130
  * only invokes {@link IpReputationOptions.onMatch} and lets the request
@@ -46,6 +46,7 @@
46
46
  */
47
47
  import { ForbiddenError } from "./errors.js";
48
48
  import { fetchGuard } from "./fetch-guard.js";
49
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
49
50
  import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-restriction.js";
50
51
  const DEFAULT_REFRESH_MS = 60 * 60_000;
51
52
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
@@ -113,15 +114,13 @@ export function urlFeed(url, opts = {}) {
113
114
  },
114
115
  };
115
116
  }
116
- function forwardedIpResolver(ctx) {
117
- const headers = ctx.request.headers;
118
- const forwarded = headers.get("x-forwarded-for");
119
- if (forwarded) {
120
- const first = forwarded.split(",")[0]?.trim();
121
- if (first)
122
- return first;
123
- }
124
- return ctx.request.headers.get("x-real-ip") ?? undefined;
117
+ /**
118
+ * @internal Read the client IP `hops` entries from the right of
119
+ * `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
120
+ * of the header; see {@link resolveForwardedClientIp}.
121
+ */
122
+ function forwardedIpResolver(hops) {
123
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
125
124
  }
126
125
  function noIpResolver(_ctx) {
127
126
  return undefined;
@@ -158,7 +157,8 @@ export function ipReputation(opts) {
158
157
  throw new Error("ipReputation(): fetchTimeoutMs must be a positive integer.");
159
158
  }
160
159
  const message = opts.message ?? DEFAULT_MESSAGE;
161
- const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
160
+ const hops = resolveForwardedTrust("ipReputation()", opts);
161
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
162
162
  // Last-known-good compiled denylist, one entry per feed so a single feed's
163
163
  // failed refresh doesn't drop the others.
164
164
  let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
@@ -222,7 +222,12 @@ export function ipReputation(opts) {
222
222
  const ready = opts.loadOnStart === false ? Promise.resolve() : refresh();
223
223
  return {
224
224
  hooks: {
225
- beforeHandle(ctx) {
225
+ // `preBody`, not `beforeHandle`: a denylist gate that short-circuits from
226
+ // `beforeHandle` is preempted by any earlier `beforeHandle` middleware that
227
+ // returns a Response first — a `responseCache()` HIT mounted above it would
228
+ // serve a denylisted address the cached body. `preBody` always runs first,
229
+ // so the feed holds regardless of mount order.
230
+ preBody(ctx) {
226
231
  const ip = resolveIp(ctx);
227
232
  if (!ip)
228
233
  return undefined; // fail-open on unresolved IP