@daloyjs/core 1.0.0-rc.7 → 1.0.0-rc.9

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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createServer } from "node:http";
6
6
  import { Readable } from "node:stream";
7
- import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
7
+ import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, DALOY_REQUEST_BODY_SOLICIT, } from "../app.js";
8
8
  import { BadRequestError } from "../errors.js";
9
9
  import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
10
10
  import { setConnInfo } from "../conn-info.js";
@@ -34,15 +34,12 @@ export function serve(app, opts = {}) {
34
34
  const connectionsCheckingInterval = connectionTimeoutMs > 0
35
35
  ? Math.max(1_000, Math.min(5_000, Math.floor(connectionTimeoutMs / 2)))
36
36
  : undefined;
37
- const server = createServer({
38
- maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
39
- ...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
40
- }, (req, res) => {
37
+ const handleRequest = (req, res, onBodyPull) => {
41
38
  // GET/HEAD: no body work, dispatch directly. Keep this first so the GET
42
39
  // hot path doesn't pay for any of the buffering bookkeeping below.
43
40
  const method = req.method;
44
41
  if (method === "GET" || method === "HEAD" || method === undefined) {
45
- dispatchToApp(app, req, res, trustProxy, undefined);
42
+ dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
46
43
  return;
47
44
  }
48
45
  // Refuse Fetch-forbidden methods (CONNECT/TRACE/TRACK) before building a
@@ -61,10 +58,60 @@ export function serve(app, opts = {}) {
61
58
  const cl = req.headers["content-length"];
62
59
  const n = cl ? Number(cl) : NaN;
63
60
  if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
64
- bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
61
+ bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes, onBodyPull), (e) => writeAdapterError(res, e));
65
62
  return;
66
63
  }
67
- dispatchToApp(app, req, res, trustProxy, undefined);
64
+ dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
65
+ };
66
+ const server = createServer({
67
+ maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
68
+ ...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
69
+ }, handleRequest);
70
+ // `Expect: 100-continue`: hold the interim response until the framework
71
+ // actually reaches for the body.
72
+ //
73
+ // Node's default is to answer `100 Continue` for anyone who asks, which
74
+ // solicits a body the framework may be about to refuse outright. A route with
75
+ // a request-body schema and a declared `Content-Length` over `bodyLimitBytes`
76
+ // is rejected by `readBodyLimited` *before* it reads a byte, so the interim
77
+ // `100` invited megabytes that could only ever be discarded — measured as
78
+ // `100` then `413` on the wire.
79
+ //
80
+ // Deferring to the first actual read makes the framework's own read decision
81
+ // the predicate, and that is what keeps this honest. An earlier attempt
82
+ // refused at header time against `bodyLimitBytes` directly, but that limit is
83
+ // only enforced where a body is *parsed*, so a route that declares no body
84
+ // schema never applies it: the same request answered `413` with `Expect` and
85
+ // `200` without it. `Expect` is a hint about when to send the body (RFC 9110
86
+ // §10.1.1), so it must never change the outcome — only when the client
87
+ // learns it. Keying off the read keeps the two paths in agreement by
88
+ // construction rather than by test coverage.
89
+ server.on("checkContinue", (req, res) => {
90
+ const cl = req.headers["content-length"];
91
+ const n = cl ? Number(cl) : NaN;
92
+ if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
93
+ // Small declared body: `handleRequest` buffers it eagerly, before dispatch,
94
+ // so there is no later read to key off. It is also under the buffer cap,
95
+ // so soliciting it immediately costs nothing worth deferring.
96
+ res.writeContinue();
97
+ handleRequest(req, res);
98
+ return;
99
+ }
100
+ // Streaming path (no `Content-Length`, or one above the buffer cap): answer
101
+ // the interim `100` the first time the framework pulls the body stream.
102
+ //
103
+ // The socket's own `resume` event is deliberately NOT the trigger. Node also
104
+ // resumes the stream on a microtask when it drains a body nobody read, which
105
+ // races the response: measured, that fired the `100` even for a request whose
106
+ // body was never wanted, and beat the `413` to the wire. The consumer's first
107
+ // `pull` is the only signal that means "the framework wants these bytes".
108
+ let sent = false;
109
+ handleRequest(req, res, () => {
110
+ if (sent || res.headersSent || res.writableEnded)
111
+ return;
112
+ sent = true;
113
+ res.writeContinue();
114
+ });
68
115
  });
69
116
  server.requestTimeout = connectionTimeoutMs;
70
117
  server.headersTimeout = connectionTimeoutMs;
@@ -148,10 +195,10 @@ export function serve(app, opts = {}) {
148
195
  * on body size remains `App.bodyLimitBytes`.
149
196
  */
150
197
  const DEFAULT_BUFFERED_BODY_MAX_BYTES = 256 * 1024;
151
- function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
198
+ function dispatchToApp(app, req, res, trustProxy, bufferedBody, onBodyPull) {
152
199
  let request;
153
200
  try {
154
- request = toWebRequest(req, trustProxy, bufferedBody);
201
+ request = toWebRequest(req, trustProxy, bufferedBody, onBodyPull);
155
202
  }
156
203
  catch (e) {
157
204
  writeAdapterError(res, e);
@@ -537,7 +584,11 @@ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
537
584
  // construction for requests dispatched through this shim. Set once on the
538
585
  // prototype: zero per-request cost.
539
586
  LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
540
- function toWebRequest(req, trustProxy, bufferedBody) {
587
+ function toWebRequest(req, trustProxy, bufferedBody, onBodyPull) {
588
+ // `onBodyPull` is attached to the finished Request below rather than wrapping
589
+ // the body stream: undici pulls a streaming body during `new Request(...)`,
590
+ // so a stream-level hook fired at construction — before the framework had
591
+ // decided anything — and re-solicited bodies it went on to refuse.
541
592
  const reqHeaders = req.headers;
542
593
  const forwardedHost = trustProxy ? firstHeader(reqHeaders["x-forwarded-host"]) : undefined;
543
594
  const host = forwardedHost ?? reqHeaders.host ?? "localhost";
@@ -588,12 +639,16 @@ function toWebRequest(req, trustProxy, bufferedBody) {
588
639
  req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
589
640
  return req2;
590
641
  }
591
- return new Request(url, {
642
+ const streamed = new Request(url, {
592
643
  method,
593
644
  headers,
594
645
  body: Readable.toWeb(req),
595
646
  duplex: "half",
596
647
  });
648
+ if (onBodyPull !== undefined) {
649
+ streamed[DALOY_REQUEST_BODY_SOLICIT] = onBodyPull;
650
+ }
651
+ return streamed;
597
652
  }
598
653
  function firstHeader(v) {
599
654
  if (v === undefined)
@@ -874,9 +929,16 @@ class NodeWebSocketConnection {
874
929
  },
875
930
  onClose: (code, reason) => {
876
931
  if (this.readyState === WS_READY_STATE.OPEN) {
877
- // Echo close per RFC 6455 §5.5.1.
932
+ // Echo close per RFC 6455 §5.5.1 ("SHOULD use the same status code").
933
+ // A peer that closed with an *empty* payload surfaces as the 1005
934
+ // sentinel, which §7.4.1 forbids on the wire — echoing it produced a
935
+ // CLOSE(1005) that a conforming peer (and this framework's own
936
+ // decoder) must reject with 1002. An empty close is answered with an
937
+ // empty close.
878
938
  this.readyState = WS_READY_STATE.CLOSING;
879
- this._writeFrame(WS_OPCODE.CLOSE, encodeClosePayload(code, reason));
939
+ this._writeFrame(WS_OPCODE.CLOSE, code === WS_CLOSE_CODE.NO_STATUS_RECEIVED
940
+ ? new Uint8Array(0)
941
+ : encodeClosePayload(code, reason));
880
942
  }
881
943
  this.readyState = WS_READY_STATE.CLOSED;
882
944
  this._fireClose(code, reason);
package/dist/app.d.ts CHANGED
@@ -793,6 +793,31 @@ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
793
793
  * adapters can opt in; not part of the userland API surface.
794
794
  */
795
795
  export declare const DALOY_REQUEST_ABORT: unique symbol;
796
+ /**
797
+ * Internal Symbol an adapter may set to a callback that the framework invokes
798
+ * immediately before it reads the request body — and only once it has decided
799
+ * the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
800
+ *
801
+ * It exists for `Expect: 100-continue`. Node answers the interim `100` to
802
+ * anyone who asks, which solicits a body the framework may be about to refuse:
803
+ * a route with a body schema and a declared `Content-Length` over the limit is
804
+ * rejected before a byte is read, so the `100` invited megabytes that could
805
+ * only be discarded. The adapter defers its `writeContinue()` into this hook so
806
+ * the invitation tracks the framework's own decision.
807
+ *
808
+ * The framework's read decision has to be the trigger, because it is the only
809
+ * thing that makes the outcome independent of the `Expect` header. `Expect` is
810
+ * a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
811
+ * time against `bodyLimitBytes` instead looks equivalent but is not, since that
812
+ * limit is only applied where a body is parsed — a route declaring no body
813
+ * schema never applies it, so the same request answered `413` with `Expect` and
814
+ * `200` without.
815
+ *
816
+ * Adapter-facing only; userland code should not depend on it.
817
+ *
818
+ * @since 1.0.0-rc.9
819
+ */
820
+ export declare const DALOY_REQUEST_BODY_SOLICIT: unique symbol;
796
821
  /**
797
822
  * Internal Symbol set by handlers/serializers to attach a raw stream
798
823
  * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
package/dist/app.js CHANGED
@@ -136,6 +136,8 @@ const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
136
136
  */
137
137
  const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
138
138
  const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
139
+ const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
140
+ const EARLY_REJECTION_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
139
141
  /**
140
142
  * Apply a topology-aware security preset on top of caller-supplied
141
143
  * options. Returns a new options object where preset defaults fill in
@@ -222,6 +224,31 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
222
224
  * adapters can opt in; not part of the userland API surface.
223
225
  */
224
226
  export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
227
+ /**
228
+ * Internal Symbol an adapter may set to a callback that the framework invokes
229
+ * immediately before it reads the request body — and only once it has decided
230
+ * the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
231
+ *
232
+ * It exists for `Expect: 100-continue`. Node answers the interim `100` to
233
+ * anyone who asks, which solicits a body the framework may be about to refuse:
234
+ * a route with a body schema and a declared `Content-Length` over the limit is
235
+ * rejected before a byte is read, so the `100` invited megabytes that could
236
+ * only be discarded. The adapter defers its `writeContinue()` into this hook so
237
+ * the invitation tracks the framework's own decision.
238
+ *
239
+ * The framework's read decision has to be the trigger, because it is the only
240
+ * thing that makes the outcome independent of the `Expect` header. `Expect` is
241
+ * a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
242
+ * time against `bodyLimitBytes` instead looks equivalent but is not, since that
243
+ * limit is only applied where a body is parsed — a route declaring no body
244
+ * schema never applies it, so the same request answered `413` with `Expect` and
245
+ * `200` without.
246
+ *
247
+ * Adapter-facing only; userland code should not depend on it.
248
+ *
249
+ * @since 1.0.0-rc.9
250
+ */
251
+ export const DALOY_REQUEST_BODY_SOLICIT = Symbol.for("daloyjs.request.bodySolicit");
225
252
  /**
226
253
  * Internal Symbol set by handlers/serializers to attach a raw stream
227
254
  * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
@@ -1028,6 +1055,29 @@ export class App {
1028
1055
  this.bootGuard.error = err;
1029
1056
  throw err;
1030
1057
  }
1058
+ // Guard 3b: a stored-response layer mounted ahead of a request budget.
1059
+ // `responseCache()` and `idempotency()` both answer from `beforeHandle` and
1060
+ // end the hook chain, and `rateLimit()` / `loginThrottle()` enforce from that
1061
+ // same phase — so a limiter mounted behind either one never counts the
1062
+ // requests it serves. Measured: `rateLimit({ max: 2 })` behind a cache or a
1063
+ // replay admitted six of six. The budget silently becomes infinite for
1064
+ // exactly the traffic that repeats most, which is what the limit was written
1065
+ // for. Same shape as the responseCache-ahead-of-gates finding, and the reason
1066
+ // the five network-identity gates moved to `preBody`; `rateLimit` cannot
1067
+ // follow them there because its `keyGenerator` is caller-supplied and may
1068
+ // read `ctx.state`, so the unsafe order is refused instead.
1069
+ const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
1070
+ if (replayBeforeBudget !== undefined && this.bootGuard.error === undefined) {
1071
+ this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
1072
+ `${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
1073
+ `effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
1074
+ `replay returns a response and ends the chain before the limiter counts the request — ` +
1075
+ `the declared budget is never spent on repeat traffic and is effectively unlimited. ` +
1076
+ `Register rateLimit() first — as a global hook (new App({ hooks: rateLimit(...) })) or ` +
1077
+ `an earlier app.use(...) — so every request is counted before a stored response can ` +
1078
+ `short-circuit it. See https://daloyjs.dev/docs/security/boot-guards.`);
1079
+ throw this.bootGuard.error;
1080
+ }
1031
1081
  // Guard 4: session() + state-changing route without csrf().
1032
1082
  if (this.options.csrf === "off")
1033
1083
  return;
@@ -3185,6 +3235,12 @@ function securityMarkersFromHooks(layers) {
3185
3235
  // to tell whether the cache reads state before tenancy has written it.
3186
3236
  let cacheIndex = -1;
3187
3237
  let tenancyIndex = -1;
3238
+ // First stored-response layer of either kind, and the first request-budget
3239
+ // layer. Only the earliest of each matters: if any replay precedes any budget
3240
+ // hook, that budget is preemptable.
3241
+ let replayIndex = -1;
3242
+ let replayName = "";
3243
+ let budgetIndex = -1;
3188
3244
  for (let i = 0; i < layers.length; i++) {
3189
3245
  const record = layers[i];
3190
3246
  if (record[SESSION_HOOK_MARKER] === true)
@@ -3197,12 +3253,25 @@ function securityMarkersFromHooks(layers) {
3197
3253
  cacheIndex = i;
3198
3254
  if (tenancyIndex === -1 && record[TENANCY_HOOK_MARKER] === true)
3199
3255
  tenancyIndex = i;
3256
+ if (replayIndex === -1) {
3257
+ if (record[RESPONSE_CACHE_HOOK_MARKER] === true) {
3258
+ replayIndex = i;
3259
+ replayName = "responseCache()";
3260
+ }
3261
+ else if (record[IDEMPOTENCY_HOOK_MARKER] === true) {
3262
+ replayIndex = i;
3263
+ replayName = "idempotency()";
3264
+ }
3265
+ }
3266
+ if (budgetIndex === -1 && Array.isArray(record[EARLY_REJECTION_MARKER]))
3267
+ budgetIndex = i;
3200
3268
  }
3201
3269
  return {
3202
3270
  hasSession,
3203
3271
  hasCsrf,
3204
3272
  hasAuth,
3205
3273
  cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
3274
+ replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex ? replayName : null,
3206
3275
  };
3207
3276
  }
3208
3277
  function isStateChangingMethod(method) {
@@ -3718,6 +3787,21 @@ function validateContext(ctx, def, opts) {
3718
3787
  if (!allowed.some((a) => ct.includes(a))) {
3719
3788
  throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3720
3789
  }
3790
+ // Refuse an over-limit *declared* length here, before soliciting the body.
3791
+ // `readBodyLimited` already makes the identical check on the identical
3792
+ // boundary, so this changes no outcome — it is load-bearing purely for
3793
+ // ordering, so that an adapter deferring `Expect: 100-continue` (see
3794
+ // {@link DALOY_REQUEST_BODY_SOLICIT}) never invites bytes this request was
3795
+ // always going to be refused for. Kept below the content-type check so a
3796
+ // wrong media type still answers `415` rather than `413`, as before.
3797
+ const declared = request.headers.get("content-length");
3798
+ if (declared !== null) {
3799
+ const declaredBytes = Number(declared);
3800
+ if (Number.isFinite(declaredBytes) && declaredBytes > opts.bodyLimitBytes) {
3801
+ throw new PayloadTooLargeError(opts.bodyLimitBytes);
3802
+ }
3803
+ }
3804
+ request[DALOY_REQUEST_BODY_SOLICIT]?.();
3721
3805
  const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
3722
3806
  if (isPromiseLike(raw))
3723
3807
  return raw.then(validateBodyAndFinish);
@@ -11,7 +11,7 @@
11
11
  * The middleware is dependency-free and runtime-portable. It observes outgoing
12
12
  * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
13
  * status produced by *any* later middleware or handler, not just its own) and
14
- * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
14
+ * enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
15
15
  * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
16
  * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
17
  * implementation for multi-instance deployments.
@@ -19,7 +19,7 @@
19
19
  * @module
20
20
  * @since 0.37.0
21
21
  */
22
- import type { BaseContext, Hooks } from "./types.js";
22
+ import type { Hooks, IdentityGateContext } from "./types.js";
23
23
  /**
24
24
  * One client's auto-ban bookkeeping. A record tracks the current strike count
25
25
  * inside the rolling strike window, when that window expires, the timestamp the
@@ -128,8 +128,22 @@ export interface AutoBanOptions {
128
128
  * Derive the client identity from `ctx`, or `undefined` to skip the request
129
129
  * (fail-open — never banned, never counted). Defaults to the proxy-header
130
130
  * resolver when {@link trustProxyHeaders} is set.
131
+ *
132
+ * Called first in `preBody`, where the gate is immune to mount order (see
133
+ * {@link IdentityGateContext}). If it returns `undefined` there, it is called
134
+ * again in `beforeHandle` — by then `session()` and other `beforeHandle` layers
135
+ * have populated `ctx.state`, so a generator keyed on a resolved session works
136
+ * rather than silently disabling the ban. Requests enforced by that second
137
+ * attempt are order-sensitive again, because `beforeHandle` is the phase a
138
+ * `responseCache()` hit short-circuits; key off headers, params or query where
139
+ * you can and the `preBody` pass handles it. Returning `undefined` from *both*
140
+ * still skips the request.
141
+ *
142
+ * `ctx.body` is not available in either phase — `preBody` runs before parsing,
143
+ * and the type reflects that. Derive the key from the request line, headers, or
144
+ * state instead.
131
145
  */
132
- keyGenerator?: (ctx: BaseContext<any, any>) => string | undefined;
146
+ keyGenerator?: (ctx: IdentityGateContext) => string | undefined;
133
147
  /**
134
148
  * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
149
  * default because those headers are client-spoofable unless every request
@@ -152,6 +166,32 @@ export interface AutoBanOptions {
152
166
  * [1, 64]; validated at construction.
153
167
  */
154
168
  trustedHops?: number;
169
+ /**
170
+ * What to do when the default key generator cannot resolve a forwarded
171
+ * identity — the request carried no `X-Forwarded-For`, or a chain shorter than
172
+ * {@link trustedHops} declares.
173
+ *
174
+ * - `"peer"` (default) — fall back to the immediate TCP peer address, in its
175
+ * own `peer:` keyspace. The peer cannot be spoofed, and a request that
176
+ * skipped the declared proxy chain came *from* that peer, so strikes are
177
+ * attributed to the real origin of the traffic.
178
+ * - `"skip"` — never count and never ban such a request.
179
+ *
180
+ * `"peer"` is the default because `"skip"` is a silent bypass: an attacker who
181
+ * can reach the origin directly gets unlimited strikes simply by omitting a
182
+ * header. Choose `"skip"` only when unresolved requests are known-benign and
183
+ * arrive from a shared address — for instance a load balancer that does not
184
+ * always set `X-Forwarded-For`, where every such request would otherwise share
185
+ * the balancer's single `peer:` bucket and a few `401`s could ban the lot.
186
+ * Prefer fixing the proxy configuration over choosing `"skip"`.
187
+ *
188
+ * Ignored when {@link keyGenerator} is supplied — a custom generator owns its
189
+ * own unresolved-identity posture, and returning `undefined` from it still
190
+ * means skip.
191
+ *
192
+ * @since 1.0.0-rc.8
193
+ */
194
+ onUnresolvedIdentity?: "peer" | "skip";
155
195
  /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
156
196
  store?: AutoBanStore;
157
197
  /**
@@ -201,8 +241,12 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
201
241
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
202
242
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
203
243
  * a misconfiguration can never collapse every caller into one shared bucket and
204
- * ban the whole world at once. A request the key generator cannot attribute is
205
- * skipped (never counted, never banned).
244
+ * ban the whole world at once. When the default generator cannot resolve a
245
+ * forwarded identity no `X-Forwarded-For`, or a chain shorter than
246
+ * {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
247
+ * unspoofable TCP peer instead of being discarded; see
248
+ * {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
249
+ * returns `undefined` still skips the request.
206
250
  *
207
251
  * @example
208
252
  * ```ts
package/dist/auto-ban.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * The middleware is dependency-free and runtime-portable. It observes outgoing
12
12
  * responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
13
13
  * status produced by *any* later middleware or handler, not just its own) and
14
- * enforces the ban in {@link "./types.js".Hooks.beforeHandle}. The ban state
14
+ * enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
15
15
  * lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
16
16
  * `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
17
17
  * implementation for multi-instance deployments.
@@ -20,7 +20,7 @@
20
20
  * @since 0.37.0
21
21
  */
22
22
  import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
- import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
23
+ import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
24
24
  const DEFAULT_WINDOW_MS = 10 * 60_000;
25
25
  const DEFAULT_MAX_STRIKES = 5;
26
26
  const DEFAULT_BAN_MS = 15 * 60_000;
@@ -90,9 +90,32 @@ function assertPositiveInteger(name, value) {
90
90
  * trusted proxy hops from the right of `X-Forwarded-For` (falling back to
91
91
  * `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
92
92
  * {@link resolveForwardedClientIp}.
93
+ *
94
+ * When the forwarded chain cannot satisfy the declaration,
95
+ * {@link resolveForwardedClientIp} returns `undefined` — correct for *identity*,
96
+ * because such a request never traversed the declared topology. For *abuse
97
+ * accounting* that answer used to mean "skip", which handed an attacker unlimited
98
+ * strikes for free: reach the origin directly, past the CDN that appends the
99
+ * header, and every failed credential attempt went uncounted.
100
+ *
101
+ * So the fallback is the immediate TCP peer, prefixed to keep it in its own
102
+ * keyspace. The peer address cannot be spoofed — it is the socket actually
103
+ * talking to the adapter — and in exactly the direct-to-origin case that
104
+ * produced the bypass, the peer *is* the attacker, so accounting becomes precise
105
+ * rather than absent. Set `onUnresolvedIdentity: "skip"` to restore the previous
106
+ * behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
107
+ * the right call.
93
108
  */
94
- function forwardedKey(hops) {
95
- return (ctx) => resolveForwardedClientIp(ctx.request, hops);
109
+ function forwardedKey(hops, peerFallback) {
110
+ return (ctx) => {
111
+ const forwarded = resolveForwardedClientIp(ctx.request, hops);
112
+ if (forwarded !== undefined)
113
+ return forwarded;
114
+ if (!peerFallback)
115
+ return undefined;
116
+ const peer = readRemoteAddress(ctx);
117
+ return peer === undefined ? undefined : `peer:${peer}`;
118
+ };
96
119
  }
97
120
  /**
98
121
  * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
@@ -103,8 +126,12 @@ function forwardedKey(hops) {
103
126
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
104
127
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
105
128
  * a misconfiguration can never collapse every caller into one shared bucket and
106
- * ban the whole world at once. A request the key generator cannot attribute is
107
- * skipped (never counted, never banned).
129
+ * ban the whole world at once. When the default generator cannot resolve a
130
+ * forwarded identity no `X-Forwarded-For`, or a chain shorter than
131
+ * {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
132
+ * unspoofable TCP peer instead of being discarded; see
133
+ * {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
134
+ * returns `undefined` still skips the request.
108
135
  *
109
136
  * @example
110
137
  * ```ts
@@ -150,12 +177,16 @@ export function autoBan(opts = {}) {
150
177
  }
151
178
  const watch = new Set(watchStatuses);
152
179
  const hops = resolveForwardedTrust("autoBan()", opts);
180
+ const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
181
+ if (onUnresolved !== "peer" && onUnresolved !== "skip") {
182
+ throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
183
+ }
153
184
  let keyOf;
154
185
  if (opts.keyGenerator) {
155
186
  keyOf = opts.keyGenerator;
156
187
  }
157
188
  else if (hops !== undefined) {
158
- keyOf = forwardedKey(hops);
189
+ keyOf = forwardedKey(hops, onUnresolved === "peer");
159
190
  }
160
191
  else {
161
192
  throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
@@ -175,23 +206,40 @@ export function autoBan(opts = {}) {
175
206
  store = shared;
176
207
  }
177
208
  const prefix = `${groupId}:`;
178
- return {
179
- async beforeHandle(ctx) {
180
- const identity = keyOf(ctx);
181
- if (identity === undefined)
182
- return undefined;
183
- const key = `${prefix}${identity}`;
184
- const state = ctx.state;
185
- state[STATE_KEY] = key;
186
- const record = await store.get(key);
187
- const now = Date.now();
188
- if (record && record.bannedUntilMs > now) {
189
- state[STATE_REJECTED] = true;
190
- if (banStatus === 403)
191
- throw new ForbiddenError(message);
192
- const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
193
- throw new TooManyRequestsError(retryAfter ? retry : undefined);
194
- }
209
+ /**
210
+ * Resolve the identity, stash the key for `onSend`, and reject an active ban.
211
+ *
212
+ * Shared by the `preBody` gate and the `beforeHandle` fallback below so both
213
+ * phases enforce identically. Returns `true` once an identity was found, so
214
+ * the fallback knows whether `preBody` already handled the request.
215
+ */
216
+ const enforce = async (ctx) => {
217
+ const identity = keyOf(ctx);
218
+ if (identity === undefined)
219
+ return false;
220
+ const key = `${prefix}${identity}`;
221
+ const state = ctx.state;
222
+ state[STATE_KEY] = key;
223
+ const record = await store.get(key);
224
+ const now = Date.now();
225
+ if (record && record.bannedUntilMs > now) {
226
+ state[STATE_REJECTED] = true;
227
+ if (banStatus === 403)
228
+ throw new ForbiddenError(message);
229
+ const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
230
+ throw new TooManyRequestsError(retryAfter ? retry : undefined);
231
+ }
232
+ return true;
233
+ };
234
+ const hooks = {
235
+ // `preBody`, not `beforeHandle`: the ban check must not be preemptable by an
236
+ // earlier `beforeHandle` middleware that short-circuits — a
237
+ // `responseCache()` HIT mounted above it would serve a banned client the
238
+ // cached body, so the ban would only ever apply to uncached routes.
239
+ // `preBody` always runs before any `beforeHandle`. Strike accounting stays in
240
+ // `onSend`, which observes the final status either way.
241
+ async preBody(ctx) {
242
+ await enforce(ctx);
195
243
  return undefined;
196
244
  },
197
245
  async onSend(res, ctx) {
@@ -227,4 +275,31 @@ export function autoBan(opts = {}) {
227
275
  return undefined;
228
276
  },
229
277
  };
278
+ // A custom `keyGenerator` may legitimately be unable to answer in `preBody` —
279
+ // typically because it reads state a `beforeHandle` layer resolves, such as
280
+ // `session()`. Without a second attempt that request gets no identity, so
281
+ // `onSend` finds no key and records no strike: the ban silently never arms.
282
+ // That is a worse failure than the ordering hazard the phase move closed, so
283
+ // retry in `beforeHandle` when, and only when, `preBody` came up empty.
284
+ //
285
+ // The default forwarded resolver never needs this — `onUnresolvedIdentity`
286
+ // already falls back to the TCP peer — so the hook is registered only for a
287
+ // custom generator and the common path pays nothing.
288
+ //
289
+ // Residual, deliberately accepted: a request enforced by this fallback IS
290
+ // order-sensitive again, because `beforeHandle` is the phase a
291
+ // `responseCache()` hit short-circuits. It applies solely to requests whose
292
+ // identity could not be resolved earlier, and enforcing late beats not
293
+ // enforcing at all. Resolve identity from headers/params/query where you can
294
+ // and `preBody` handles it, immune to mount order.
295
+ if (opts.keyGenerator) {
296
+ hooks.beforeHandle = async (ctx) => {
297
+ const state = ctx.state;
298
+ if (state[STATE_KEY] !== undefined)
299
+ return undefined; // preBody had it
300
+ await enforce(ctx);
301
+ return undefined;
302
+ };
303
+ }
304
+ return hooks;
230
305
  }
@@ -35,7 +35,7 @@
35
35
  * @module
36
36
  * @since 0.37.0
37
37
  */
38
- import type { BaseContext, Hooks } from "./types.js";
38
+ import type { Hooks, IdentityGateContext } from "./types.js";
39
39
  /**
40
40
  * Pluggable DNS resolver used to verify declared crawlers. The default
41
41
  * implementation lazily imports `node:dns/promises`; provide your own on
@@ -150,7 +150,7 @@ export interface BotGuardOptions {
150
150
  /**
151
151
  * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
152
152
  */
153
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
153
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
154
154
  /**
155
155
  * Custom DNS resolver for crawler verification. Defaults to a lazy
156
156
  * `node:dns/promises`-backed resolver.
package/dist/bot-guard.js CHANGED
@@ -260,7 +260,12 @@ export function botGuard(opts = {}) {
260
260
  throw new ForbiddenError(message);
261
261
  };
262
262
  return {
263
- async beforeHandle(ctx) {
263
+ // `preBody`, not `beforeHandle`: a bot gate that short-circuits from
264
+ // `beforeHandle` loses to any earlier `beforeHandle` middleware that returns
265
+ // a Response first — a `responseCache()` HIT mounted above it would hand a
266
+ // blocked scraper the cached body. `preBody` always precedes `beforeHandle`,
267
+ // so the gate holds regardless of mount order.
268
+ async preBody(ctx) {
264
269
  const ua = ctx.request.headers.get("user-agent") ?? "";
265
270
  // Allowlist wins over every other rule.
266
271
  if (allowed.length > 0 && matchesUserAgent(ua, allowed))
@@ -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,7 +110,7 @@ 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
package/dist/geo-block.js CHANGED
@@ -107,7 +107,13 @@ export function geoBlock(opts) {
107
107
  const hops = resolveForwardedTrust("geoBlock()", opts);
108
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) {