@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/README.md CHANGED
@@ -598,7 +598,8 @@ DaloyJS is at **`1.0.0-rc.6`**, a security-hardening release candidate carrying
598
598
  - Header/JWT/basic/mTLS authentication runs in the `preBody` phase before request-body I/O; body-aware WAF, idempotency, signature, and application middleware keep the validated `beforeHandle` phase.
599
599
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
600
600
  - AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
601
- - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
601
+ - Project docs site serves a curated [`/llms.txt`](https://daloyjs.dev/llms.txt) index (Markdown map + `.md` siblings of every docs page, blog under `Optional`) so coding agents can load documentation without scraping HTML; see [llms.txt docs](https://daloyjs.dev/docs/llms-txt).
602
+ - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`, speaking the **stateless MCP `2026-07-28`** revision and every earlier one on the same endpoint: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Modern requests get `server/discover`, per-request `_meta` validation, `resultType` + `_meta.serverInfo` on every result, `ttlMs` / `cacheScope` caching hints (defaulting to no caching and `private` scope so an authorization-scoped tool list is never shared by a proxy), multi round-trip requests (`input_required` + client retry, replacing server-initiated elicitation/sampling/roots), and `x-mcp-header` parameter mirroring — with a missing or disagreeing `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` header rejected as `-32020` so a gateway can never route on one value while the tool executes another. Legacy clients keep the `initialize` handshake unchanged. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
602
603
  - API lifecycle and breaking-change detection: mark routes `deprecated` or give them a `sunset` date to emit RFC 8594 `Deprecation` / `Sunset` headers and an `x-sunset` OpenAPI extension, then gate CI with `diffOpenAPI()` / the `daloy diff` command, which fail on a breaking change versus the last published spec.
603
604
  - In-process test client (`app.request()`), contract-test runner (gated in CI via `daloy inspect --check` and shipped as a default test in every `create-daloy` template), in-process typed client, and Hey API codegen via `pnpm gen`.
604
605
 
@@ -665,7 +666,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
665
666
  - `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
666
667
  - `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
667
668
  - `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
668
- - `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator` or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
669
+ - `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator`, `trustedHops`, or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Proxy-header identity is **spoof-resistant**: the client IP is read from the rightmost `X-Forwarded-For` entry (the one your proxy appended) via `resolveForwardedClientIp()`, so rotating spoofed left entries can neither evade strike accumulation nor frame a victim IP for banning; multi-hop chains declare their hop count with `trustedHops` (shared by `rateLimit()`, `loginThrottle()`, `concurrencyLimit()`, `geoBlock()`, `ipRestriction()`, `ipReputation()`, and `botGuard()`). Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
669
670
  - `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
670
671
  - `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed; **SSRF-hardened by default** — the outbound fetch runs through `fetchGuard()`, so a compromised feed host can't redirect it into cloud-metadata / internal space; override via `fetchImpl`) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
671
672
  - `geoBlock()` GeoIP / geo-blocking at `@daloyjs/core/geo-block`: country allow/deny middleware that maps the client IP to an ISO 3166-1 alpha-2 country and rejects (or logs) traffic from countries you don't serve. **No bundled GeoIP database and no runtime dependency** — supply either an operator-owned `lookupCountry(ip)` (a MaxMind / `ip2location` reader, or your own table, reusing the trusted-proxy `X-Forwarded-For` / `X-Real-IP` IP resolution) or a `resolveCountry(ctx)` that reads an edge-injected header (`CF-IPCountry`, `CloudFront-Viewer-Country`, `x-vercel-ip-country`). Deny wins over allow (least privilege); **allow-lists fail closed** on an unknown country while deny-only fails open (overridable via `allowUnknownCountry`). Country codes are validated at construction so typos throw instead of silently never matching. `mode: "log"` monitor mode with an `onBlock` decision hook (`denied_country` / `not_in_allowlist` / `unknown_country`), the resolved country stamped on `ctx.state.geo` for allowed requests, and a `403` problem+json rejection that never echoes the country/IP. Zero runtime dependencies.
@@ -874,9 +874,16 @@ class NodeWebSocketConnection {
874
874
  },
875
875
  onClose: (code, reason) => {
876
876
  if (this.readyState === WS_READY_STATE.OPEN) {
877
- // Echo close per RFC 6455 §5.5.1.
877
+ // Echo close per RFC 6455 §5.5.1 ("SHOULD use the same status code").
878
+ // A peer that closed with an *empty* payload surfaces as the 1005
879
+ // sentinel, which §7.4.1 forbids on the wire — echoing it produced a
880
+ // CLOSE(1005) that a conforming peer (and this framework's own
881
+ // decoder) must reject with 1002. An empty close is answered with an
882
+ // empty close.
878
883
  this.readyState = WS_READY_STATE.CLOSING;
879
- this._writeFrame(WS_OPCODE.CLOSE, encodeClosePayload(code, reason));
884
+ this._writeFrame(WS_OPCODE.CLOSE, code === WS_CLOSE_CODE.NO_STATUS_RECEIVED
885
+ ? new Uint8Array(0)
886
+ : encodeClosePayload(code, reason));
880
887
  }
881
888
  this.readyState = WS_READY_STATE.CLOSED;
882
889
  this._fireClose(code, reason);
@@ -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,14 +128,70 @@ 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
136
150
  * reaches the app through a proxy chain you control.
151
+ *
152
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
153
+ * one your immediate proxy appended — never the attacker-influenceable
154
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
155
+ * instead so the key comes from the slot your outermost trusted proxy wrote.
137
156
  */
138
157
  trustProxyHeaders?: boolean;
158
+ /**
159
+ * Declare exactly how many proxy hops sit between Daloy and the public
160
+ * internet. Implies proxy-header trust (no need to also set
161
+ * {@link trustProxyHeaders}) and reads the client IP that many entries from
162
+ * the right of `X-Forwarded-For` via
163
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so spoofed entries an
164
+ * attacker prepends on the left are ignored — they can neither evade strike
165
+ * accumulation nor frame a victim IP for banning. Must be an integer in
166
+ * [1, 64]; validated at construction.
167
+ */
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";
139
195
  /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
140
196
  store?: AutoBanStore;
141
197
  /**
@@ -185,8 +241,12 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
185
241
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
186
242
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
187
243
  * a misconfiguration can never collapse every caller into one shared bucket and
188
- * ban the whole world at once. A request the key generator cannot attribute is
189
- * 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.
190
250
  *
191
251
  * @example
192
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,6 +20,7 @@
20
20
  * @since 0.37.0
21
21
  */
22
22
  import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
+ import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
23
24
  const DEFAULT_WINDOW_MS = 10 * 60_000;
24
25
  const DEFAULT_MAX_STRIKES = 5;
25
26
  const DEFAULT_BAN_MS = 15 * 60_000;
@@ -84,12 +85,37 @@ function assertPositiveInteger(name, value) {
84
85
  throw new Error(`autoBan(): ${name} must be a positive integer.`);
85
86
  }
86
87
  }
87
- function forwardedKey(ctx) {
88
- const forwarded = ctx.request.headers.get("x-forwarded-for");
89
- const first = forwarded ? forwarded.split(",")[0].trim() : "";
90
- if (first)
91
- return first;
92
- return ctx.request.headers.get("x-real-ip") ?? undefined;
88
+ /**
89
+ * @internal Default identity resolver: the client IP at the declared number of
90
+ * trusted proxy hops from the right of `X-Forwarded-For` (falling back to
91
+ * `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
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.
108
+ */
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
+ };
93
119
  }
94
120
  /**
95
121
  * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
@@ -100,8 +126,12 @@ function forwardedKey(ctx) {
100
126
  * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
101
127
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
102
128
  * a misconfiguration can never collapse every caller into one shared bucket and
103
- * ban the whole world at once. A request the key generator cannot attribute is
104
- * 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.
105
135
  *
106
136
  * @example
107
137
  * ```ts
@@ -146,11 +176,22 @@ export function autoBan(opts = {}) {
146
176
  }
147
177
  }
148
178
  const watch = new Set(watchStatuses);
149
- if (!opts.keyGenerator && !opts.trustProxyHeaders) {
150
- throw new Error("autoBan(): provide keyGenerator or set trustProxyHeaders so clients can be identified; " +
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
+ }
184
+ let keyOf;
185
+ if (opts.keyGenerator) {
186
+ keyOf = opts.keyGenerator;
187
+ }
188
+ else if (hops !== undefined) {
189
+ keyOf = forwardedKey(hops, onUnresolved === "peer");
190
+ }
191
+ else {
192
+ throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
151
193
  "otherwise every caller shares one bucket and a single offender would ban everyone.");
152
194
  }
153
- const keyOf = opts.keyGenerator ?? forwardedKey;
154
195
  const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
155
196
  let store;
156
197
  if (opts.store) {
@@ -165,23 +206,40 @@ export function autoBan(opts = {}) {
165
206
  store = shared;
166
207
  }
167
208
  const prefix = `${groupId}:`;
168
- return {
169
- async beforeHandle(ctx) {
170
- const identity = keyOf(ctx);
171
- if (identity === undefined)
172
- return undefined;
173
- const key = `${prefix}${identity}`;
174
- const state = ctx.state;
175
- state[STATE_KEY] = key;
176
- const record = await store.get(key);
177
- const now = Date.now();
178
- if (record && record.bannedUntilMs > now) {
179
- state[STATE_REJECTED] = true;
180
- if (banStatus === 403)
181
- throw new ForbiddenError(message);
182
- const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
183
- throw new TooManyRequestsError(retryAfter ? retry : undefined);
184
- }
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);
185
243
  return undefined;
186
244
  },
187
245
  async onSend(res, ctx) {
@@ -217,4 +275,31 @@ export function autoBan(opts = {}) {
217
275
  return undefined;
218
276
  },
219
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;
220
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
@@ -131,12 +131,26 @@ export interface BotGuardOptions {
131
131
  /**
132
132
  * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
133
133
  * enable behind a trusted proxy that overwrites these headers.
134
+ *
135
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
136
+ * entry — the one your immediate proxy appended — never the
137
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
138
+ * {@link trustedHops} instead.
134
139
  */
135
140
  trustProxyHeaders?: boolean;
141
+ /**
142
+ * Declare exactly how many proxy hops sit between Daloy and the public
143
+ * internet. Implies proxy-header trust and reads the client IP that many
144
+ * entries from the right of `X-Forwarded-For` via
145
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
146
+ * entries on the left cannot impersonate a verified crawler's IP. Must be
147
+ * an integer in [1, 64]; validated at construction.
148
+ */
149
+ trustedHops?: number;
136
150
  /**
137
151
  * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
138
152
  */
139
- resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
153
+ resolveIp?: (ctx: IdentityGateContext) => string | undefined;
140
154
  /**
141
155
  * Custom DNS resolver for crawler verification. Defaults to a lazy
142
156
  * `node:dns/promises`-backed resolver.
package/dist/bot-guard.js CHANGED
@@ -36,6 +36,7 @@
36
36
  * @since 0.37.0
37
37
  */
38
38
  import { ForbiddenError } from "./errors.js";
39
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
39
40
  const DEFAULT_MESSAGE = "Bot access denied";
40
41
  const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
41
42
  const DEFAULT_CACHE_MAX = 10_000;
@@ -88,15 +89,13 @@ function matchesUserAgent(ua, patterns) {
88
89
  }
89
90
  return false;
90
91
  }
91
- function forwardedIpResolver(ctx) {
92
- const headers = ctx.request.headers;
93
- const forwarded = headers.get("x-forwarded-for");
94
- if (forwarded) {
95
- const first = forwarded.split(",")[0]?.trim();
96
- if (first)
97
- return first;
98
- }
99
- return ctx.request.headers.get("x-real-ip") ?? undefined;
92
+ /**
93
+ * @internal Read the client IP `hops` entries from the right of
94
+ * `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
95
+ * of the header; see {@link resolveForwardedClientIp}.
96
+ */
97
+ function forwardedIpResolver(hops) {
98
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
100
99
  }
101
100
  function noIpResolver(_ctx) {
102
101
  return undefined;
@@ -210,9 +209,10 @@ export function botGuard(opts = {}) {
210
209
  if (mode !== "block" && mode !== "log") {
211
210
  throw new Error('botGuard(): mode must be "block" or "log".');
212
211
  }
213
- const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
214
- if (verifiedBots.length > 0 && !opts.resolveIp && !opts.trustProxyHeaders) {
215
- throw new Error("botGuard(): verifiedBots requires a client-IP source provide resolveIp " +
212
+ const hops = resolveForwardedTrust("botGuard()", opts);
213
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
214
+ if (verifiedBots.length > 0 && !opts.resolveIp && hops === undefined) {
215
+ throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp, trustedHops, " +
216
216
  "or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
217
217
  }
218
218
  const resolver = opts.resolver ?? createDefaultResolver();
@@ -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))
@@ -97,8 +97,22 @@ export interface ConcurrencyLimitOptions {
97
97
  * Read `X-Forwarded-For` / `X-Real-IP` when `scope: "client"`. Off by default
98
98
  * because those headers are client-spoofable unless every request reaches the
99
99
  * app through a proxy chain you control.
100
+ *
101
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
102
+ * one your immediate proxy appended — never the attacker-influenceable
103
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
104
+ * instead.
100
105
  */
101
106
  trustProxyHeaders?: boolean;
107
+ /**
108
+ * Declare exactly how many proxy hops sit between Daloy and the public
109
+ * internet when `scope: "client"`. Implies proxy-header trust and reads the
110
+ * client IP that many entries from the right of `X-Forwarded-For` via
111
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
112
+ * entries on the left cannot hop buckets to hoard slots. Must be an integer
113
+ * in [1, 64]; validated at construction.
114
+ */
115
+ trustedHops?: number;
102
116
  /**
103
117
  * Custom client-identity resolver for `scope: "client"`. Overrides
104
118
  * {@link trustProxyHeaders}. Returning `undefined` skips limiting for the
@@ -46,6 +46,7 @@
46
46
  * @since 0.37.0
47
47
  */
48
48
  import { HttpError } from "./errors.js";
49
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
49
50
  const DEFAULT_MESSAGE = "Concurrency limit exceeded";
50
51
  /** Monotonic id so multiple mounted limiters use distinct per-request state slots. */
51
52
  let instanceCounter = 0;
@@ -59,12 +60,13 @@ function assertNonNegativeInteger(name, value) {
59
60
  throw new Error(`concurrencyLimit(): ${name} must be a non-negative integer.`);
60
61
  }
61
62
  }
62
- function forwardedKey(ctx) {
63
- const forwarded = ctx.request.headers.get("x-forwarded-for");
64
- const first = forwarded ? forwarded.split(",")[0].trim() : "";
65
- if (first)
66
- return first;
67
- return ctx.request.headers.get("x-real-ip") ?? undefined;
63
+ /**
64
+ * @internal Default identity resolver: the client IP `hops` entries from the
65
+ * right of `X-Forwarded-For` (falling back to `X-Real-IP`) the
66
+ * spoof-resistant side of the header; see {@link resolveForwardedClientIp}.
67
+ */
68
+ function forwardedKey(hops) {
69
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
68
70
  }
69
71
  /** Extract just the pathname from a request URL without a full `URL` parse where possible. */
70
72
  function pathnameOf(url) {
@@ -104,11 +106,18 @@ function buildScopeResolver(opts) {
104
106
  return (ctx) => `${ctx.request.method} ${pathnameOf(ctx.request.url)}`;
105
107
  }
106
108
  // scope === "client"
107
- if (!opts.keyGenerator && !opts.trustProxyHeaders) {
108
- throw new Error('concurrencyLimit(): scope "client" requires keyGenerator or trustProxyHeaders so ' +
109
+ const hops = resolveForwardedTrust("concurrencyLimit()", opts);
110
+ let resolve;
111
+ if (opts.keyGenerator) {
112
+ resolve = opts.keyGenerator;
113
+ }
114
+ else if (hops !== undefined) {
115
+ resolve = forwardedKey(hops);
116
+ }
117
+ else {
118
+ throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, or trustProxyHeaders so ' +
109
119
  "clients can be identified; otherwise every caller shares one bucket.");
110
120
  }
111
- const resolve = opts.keyGenerator ?? forwardedKey;
112
121
  return (ctx) => {
113
122
  const id = resolve(ctx);
114
123
  return id === undefined ? undefined : `client:${id}`;
@@ -111,6 +111,71 @@ export declare function assertBehindProxy(cfg: BehindProxyConfig | undefined): v
111
111
  * @internal
112
112
  */
113
113
  export declare function pickForwardedForByHops(header: string | null, hops: number): string | undefined;
114
+ /**
115
+ * Validate a middleware's forwarded-header trust options and resolve them into
116
+ * a single hop count. Every middleware that keys on client IP calls this once
117
+ * at construction, so the trust policy lives in exactly one place instead of
118
+ * being re-derived at each call site.
119
+ *
120
+ * That single-source property is the point: the spoofable-IP vulnerability this
121
+ * module now guards against existed in nine independent copies of the same
122
+ * leftmost-`X-Forwarded-For` read, which meant nine separate places to get it
123
+ * wrong and nine separate fixes. Keeping the decision here means a future
124
+ * change to the trust rules lands everywhere at once.
125
+ *
126
+ * `trustedHops` must be an integer in [1, 64], mirroring the
127
+ * `behindProxy.hops` range: the floor of one exists because a middleware that
128
+ * trusts zero proxy hops has no business reading forwarding headers at all.
129
+ *
130
+ * @param name - Middleware function name used in error messages.
131
+ * @param opts - The middleware's options object; only the two trust fields are
132
+ * read, so any middleware option type is structurally acceptable.
133
+ * @returns The number of trusted proxy hops when forwarded-header trust is
134
+ * enabled — `trustedHops` verbatim, or `1` for a bare
135
+ * `trustProxyHeaders: true` — or `undefined` when trust is off and the caller
136
+ * must not read forwarding headers at all.
137
+ * @throws Error when `trustedHops` is not an integer in [1, 64], or when
138
+ * `trustProxyHeaders: false` is combined with a `trustedHops` value. That
139
+ * pairing is a contradiction, and it previously resolved silently in favour
140
+ * of trust — meaning an explicit opt-out was ignored.
141
+ * @internal
142
+ */
143
+ export declare function resolveForwardedTrust(name: string, opts: {
144
+ trustedHops?: number;
145
+ trustProxyHeaders?: boolean;
146
+ }): number | undefined;
147
+ /**
148
+ * Resolve the client IP from the proxy-set forwarding headers, walking a
149
+ * declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
150
+ *
151
+ * The right side is the spoof-resistant side: each proxy in the chain appends
152
+ * the address of the peer it actually observed, so the last `hops` entries
153
+ * were written by infrastructure you control, while anything further left is
154
+ * attacker-influenceable. Reading the leftmost entry (the historic
155
+ * `split(",")[0]` pattern) trusts the MOST attacker-controllable slot and
156
+ * enabled both rate-limit/ban evasion (rotate a spoofed left entry) and
157
+ * victim-IP framing (spoof a victim's address to get them banned or blocked).
158
+ *
159
+ * Falls back to `X-Real-IP` **only for a single declared hop**. That header
160
+ * carries exactly one hop of information, so it can stand in for a one-proxy
161
+ * declaration (the common nginx `X-Real-IP`-only setup) but cannot possibly
162
+ * satisfy a two-or-more-hop one. With 2+ declared hops and a chain shorter
163
+ * than the declaration, this returns `undefined` rather than guessing.
164
+ *
165
+ * Security note: this resolver is only meaningful when every request reaches
166
+ * the app through a proxy chain you control that appends (or overwrites)
167
+ * these headers. With no proxy in front, any forwarded-header trust is
168
+ * attacker-controlled by definition.
169
+ *
170
+ * @param request - Incoming request whose forwarding headers are read.
171
+ * @param hops - Number of trusted proxy hops; `1` (default) reads the
172
+ * rightmost entry — the one your immediate proxy appended.
173
+ * @returns The resolved client IP, or `undefined` when no forwarded identity
174
+ * is available. Callers decide their own posture for `undefined`
175
+ * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
176
+ * @since 1.0.0-rc.7
177
+ */
178
+ export declare function resolveForwardedClientIp(request: Request, hops?: number): string | undefined;
114
179
  /**
115
180
  * Resolve the client IP for this request using the configured
116
181
  * {@link BehindProxyConfig}. Returns `undefined` when no trusted source is