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

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.
@@ -134,8 +134,24 @@ export interface AutoBanOptions {
134
134
  * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
135
  * default because those headers are client-spoofable unless every request
136
136
  * reaches the app through a proxy chain you control.
137
+ *
138
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
139
+ * one your immediate proxy appended — never the attacker-influenceable
140
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
141
+ * instead so the key comes from the slot your outermost trusted proxy wrote.
137
142
  */
138
143
  trustProxyHeaders?: boolean;
144
+ /**
145
+ * Declare exactly how many proxy hops sit between Daloy and the public
146
+ * internet. Implies proxy-header trust (no need to also set
147
+ * {@link trustProxyHeaders}) and reads the client IP that many entries from
148
+ * the right of `X-Forwarded-For` via
149
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so spoofed entries an
150
+ * attacker prepends on the left are ignored — they can neither evade strike
151
+ * accumulation nor frame a victim IP for banning. Must be an integer in
152
+ * [1, 64]; validated at construction.
153
+ */
154
+ trustedHops?: number;
139
155
  /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
140
156
  store?: AutoBanStore;
141
157
  /**
package/dist/auto-ban.js CHANGED
@@ -20,6 +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
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,14 @@ 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
+ function forwardedKey(hops) {
95
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
93
96
  }
94
97
  /**
95
98
  * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
@@ -146,11 +149,18 @@ export function autoBan(opts = {}) {
146
149
  }
147
150
  }
148
151
  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; " +
152
+ const hops = resolveForwardedTrust("autoBan()", opts);
153
+ let keyOf;
154
+ if (opts.keyGenerator) {
155
+ keyOf = opts.keyGenerator;
156
+ }
157
+ else if (hops !== undefined) {
158
+ keyOf = forwardedKey(hops);
159
+ }
160
+ else {
161
+ throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
151
162
  "otherwise every caller shares one bucket and a single offender would ban everyone.");
152
163
  }
153
- const keyOf = opts.keyGenerator ?? forwardedKey;
154
164
  const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
155
165
  let store;
156
166
  if (opts.store) {
@@ -131,8 +131,22 @@ 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
  */
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();
@@ -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
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
  }
@@ -116,8 +116,23 @@ export interface GeoBlockOptions {
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,7 +104,8 @@ 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
110
  async beforeHandle(ctx) {
111
111
  let ip;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ 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";
@@ -19,8 +19,8 @@ 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";
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";
@@ -109,8 +109,22 @@ export interface IpReputationOptions {
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: [] }));