@daloyjs/core 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -584,7 +584,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
584
584
 
585
585
  ## Status
586
586
 
587
- DaloyJS is at **`1.0.0`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
587
+ DaloyJS is at **`1.1.0`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
588
588
 
589
589
  The release-candidate train that led here was largely adversarial: `rc.1` through `rc.9` carried remediations from live over-the-wire engagements against realistic multi-tenant apps, and the findings clustered in one place worth naming — **composition**, not individual modules. A `responseCache()` mounted ahead of the network-identity gates silently disabled them; the same order left `rateLimit()` never counting the requests a cache hit or an idempotent replay served; a forwarded-header resolver read the one `X-Forwarded-For` slot an attacker controls; `idempotency()` replayed a stored `Set-Cookie`. Each is fixed, each has a regression test, and several are now refuse-to-boot guards so the unsafe wiring cannot ship quietly. See the [CHANGELOG](CHANGELOG.md) for the full train and [boot guards](https://daloyjs.dev/docs/security/boot-guards) for the orders the framework now refuses.
590
590
 
@@ -637,7 +637,8 @@ The framework refuses to start (or to construct) when configuration is unsafe:
637
637
  - **Unauthenticated MCP**: an `mcpRoutes()` endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server with `mcpRoutes(path, handler, { public: true })`.
638
638
  - `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
639
639
  - `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
640
- - `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
640
+ - `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2), and refuse JWS `crit` headers at both sign and verify time (RFC 7515 §4.1.11 — no extensions are implemented, so none may be silently ignored). The verifier also accepts an opt-in `maxLifetimeSeconds` cap that rejects any token whose `exp - (iat ?? now)` exceeds it (`lifetime_exceeded`), closing the signer/verifier lifetime asymmetry.
641
+ - Node adapter header-count hardening: because Node's llhttp parser *silently truncates* headers past `server.maxHeadersCount` instead of rejecting, the adapter answers `431` for any HTTP request *or WebSocket upgrade* whose raw header-field count reaches the cap — a truncated flood is indistinguishable from a handshake sitting exactly at it, so it is refused rather than completed with dropped fields (usable default budget: 99 fields).
641
642
  - `secureHeaders()` refuses to construct with `frameOptions: false` AND no CSP `frame-ancestors` directive (no clickjacking defense).
642
643
  - `cors()` refuses `methods: ['*']` at construction; default `allowMethods` narrowed to `[GET, HEAD, POST]` so `PUT` / `PATCH` / `DELETE` become explicit opt-ins.
643
644
  - `cspReportRoute()` refuses non-`application/json` (415) and refuses `maxBodyBytes > 64 KiB` at construction. The default production logger sink omits the parsed report body unless `logCspReportBodies: true` is set explicitly.
@@ -670,7 +671,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
670
671
  - `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.
671
672
  - `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.
672
673
  - `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.
673
- - `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.
674
+ - `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()`). For deployments where the origin itself is reachable, `trustedProxies` (a CIDR allowlist of your proxy peer addresses, accepted by every guard in that list) goes further: the immediate TCP peer is verified against the allowlist before any forwarded header is believed, so a direct-to-origin caller's spoofed `X-Forwarded-For` is ignored entirely — closing victim-IP framing and ban/limit evasion at the framework layer, and failing closed on peer-less edge platforms. 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.
674
675
  - `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.
675
676
  - `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.
676
677
  - `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.
@@ -37,7 +37,12 @@ export interface NodeServerOptions {
37
37
  * amplification (the dimension abused by the "HTTP/2 Bomb"). Node's own
38
38
  * default is `2000`; this adapter tightens it to `100` to mirror the
39
39
  * application-tier cap. Set `0` to disable (use Node's unbounded default).
40
- * Default: 100.
40
+ *
41
+ * Note: Node's parser (llhttp) *silently truncates* headers past this cap
42
+ * rather than rejecting the request, so the adapter additionally refuses
43
+ * any request *or WebSocket upgrade* whose raw field count reaches the cap
44
+ * with `431` — at the cap, a truncated flood is indistinguishable from a
45
+ * legitimate handshake. Default: 100.
41
46
  *
42
47
  * @since 0.38.0
43
48
  */
@@ -35,6 +35,15 @@ export function serve(app, opts = {}) {
35
35
  ? Math.max(1_000, Math.min(5_000, Math.floor(connectionTimeoutMs / 2)))
36
36
  : undefined;
37
37
  const handleRequest = (req, res, onBodyPull) => {
38
+ // Node's HTTP parser *silently truncates* headers past
39
+ // `server.maxHeadersCount` instead of rejecting the request (llhttp
40
+ // semantics — verified live against Node 24). See
41
+ // {@link isAtOrOverParserHeaderCap}. Cost: two property reads + compare.
42
+ const headerCap = server.maxHeadersCount ?? 0;
43
+ if (isAtOrOverParserHeaderCap(req, headerCap)) {
44
+ writeHeadersTooLarge(res, headerCap);
45
+ return;
46
+ }
38
47
  // GET/HEAD: no body work, dispatch directly. Keep this first so the GET
39
48
  // hot path doesn't pay for any of the buffering bookkeeping below.
40
49
  const method = req.method;
@@ -132,6 +141,20 @@ export function serve(app, opts = {}) {
132
141
  server.on("upgrade", (req, socket, head) => {
133
142
  wsSockets.add(socket);
134
143
  socket.on("close", () => wsSockets.delete(socket));
144
+ // Same llhttp truncation blind spot as handleRequest: upgrades never
145
+ // pass through the request listener or App.dispatch, so the portable
146
+ // maxHeaderCount guard never runs. Refuse at the parser cap before
147
+ // any Headers construction / 101 handshake.
148
+ const headerCap = server.maxHeadersCount ?? 0;
149
+ if (isAtOrOverParserHeaderCap(req, headerCap)) {
150
+ try {
151
+ writeUpgradeError(socket, 431, "Request Header Fields Too Large");
152
+ }
153
+ catch {
154
+ /* socket already closed */
155
+ }
156
+ return;
157
+ }
135
158
  // Safety net: a rejection here would otherwise be unhandled and, under
136
159
  // the production crash-on-unhandledRejection posture, kill the process
137
160
  // from a single malformed upgrade request.
@@ -327,6 +350,37 @@ function attachClientCertificate(req, request) {
327
350
  * defensively for runtimes/proxies that surface it as a normal request.)
328
351
  */
329
352
  const FETCH_FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
353
+ /**
354
+ * True when Node's parser has retained at least `headerCap` raw header fields.
355
+ * At the cap a truncated flood is indistinguishable from a legitimate request
356
+ * sitting exactly on the limit (llhttp drops excess fields instead of 431).
357
+ * A non-positive `headerCap` disables the check (`maxHeaderCount: 0`).
358
+ */
359
+ function isAtOrOverParserHeaderCap(req, headerCap) {
360
+ return headerCap > 0 && req.rawHeaders.length / 2 >= headerCap;
361
+ }
362
+ /**
363
+ * Refuse a request whose raw header-field count reached the parser cap —
364
+ * indistinguishable from a flood that llhttp silently truncated. Mirrors
365
+ * {@link writeMethodRefused}'s RFC 9457 problem+json shape; `Connection: close`
366
+ * avoids reusing a socket whose remaining header fields were discarded.
367
+ *
368
+ * @param res - The Node {@link ServerResponse} to write the refusal to.
369
+ * @param cap - The effective parser header-count cap, echoed in the `detail`.
370
+ */
371
+ function writeHeadersTooLarge(res, cap) {
372
+ if (res.headersSent)
373
+ return;
374
+ res.statusCode = 431;
375
+ res.setHeader("content-type", "application/problem+json");
376
+ res.setHeader("connection", "close");
377
+ res.end(JSON.stringify({
378
+ type: "https://daloyjs.dev/errors/request-header-fields-too-large",
379
+ title: "Request Header Fields Too Large",
380
+ status: 431,
381
+ detail: `Request reached the parser header-count cap (${cap}); excess fields would have been silently dropped.`,
382
+ }));
383
+ }
330
384
  /**
331
385
  * Refuse a Fetch-forbidden HTTP method with a spec-correct `501 Not
332
386
  * Implemented`. `501` is more accurate than `405` here because the method is
@@ -92,9 +92,10 @@ export interface AutoBanStrikeEvent {
92
92
  }
93
93
  /**
94
94
  * Configuration for {@link autoBan}. Every field is optional except that the
95
- * middleware must be able to identify clients: supply a {@link keyGenerator} or
96
- * set {@link trustProxyHeaders} (otherwise construction throws, to avoid
97
- * accidentally banning every client through a shared `"global"` bucket).
95
+ * middleware must be able to identify clients: supply a {@link keyGenerator},
96
+ * {@link trustedHops}, {@link trustedProxies}, or set {@link trustProxyHeaders}
97
+ * (otherwise construction throws, to avoid accidentally banning every client
98
+ * through a shared `"global"` bucket).
98
99
  *
99
100
  * @since 0.37.0
100
101
  */
@@ -166,6 +167,19 @@ export interface AutoBanOptions {
166
167
  * [1, 64]; validated at construction.
167
168
  */
168
169
  trustedHops?: number;
170
+ /**
171
+ * Declare WHICH proxies are yours: an IP/CIDR allowlist (e.g.
172
+ * `["10.0.0.0/8", "203.0.113.10"]`) for the immediate peer's address.
173
+ * Forwarded headers are honoured only when the TCP socket actually talking
174
+ * to the adapter matches the list — the one property a remote client
175
+ * cannot spoof. A direct-to-origin attacker then falls back to the
176
+ * unspoofable peer identity, so a spoofed `X-Forwarded-For` can neither
177
+ * frame a victim IP for banning nor rotate away strike accumulation.
178
+ * Implies proxy-header trust at one hop unless {@link trustedHops} says
179
+ * otherwise; validated and compiled at construction. On peer-less edge
180
+ * platforms verification fails closed (forwarded identity ignored).
181
+ */
182
+ trustedProxies?: readonly string[];
169
183
  /**
170
184
  * What to do when the default key generator cannot resolve a forwarded
171
185
  * identity — the request carried no `X-Forwarded-For`, or a chain shorter than
@@ -238,7 +252,8 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
238
252
  * offenders; bans grow exponentially for persistent abuse and decay once the
239
253
  * client goes quiet.
240
254
  *
241
- * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
255
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator},
256
+ * {@link AutoBanOptions.trustedHops}, {@link AutoBanOptions.trustedProxies}, or
242
257
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
243
258
  * a misconfiguration can never collapse every caller into one shared bucket and
244
259
  * ban the whole world at once. When the default generator cannot resolve a
@@ -258,8 +273,8 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
258
273
  *
259
274
  * @param opts - Auto-ban configuration.
260
275
  * @returns A {@link Hooks} bundle ready for `app.use(...)`.
261
- * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
262
- * or when a numeric option is out of range.
276
+ * @throws Error when none of `keyGenerator`, `trustedHops`, `trustedProxies`, or
277
+ * `trustProxyHeaders` is provided, or when a numeric option is out of range.
263
278
  * @since 0.37.0
264
279
  */
265
280
  export declare function autoBan(opts?: AutoBanOptions): Hooks;
package/dist/auto-ban.js CHANGED
@@ -20,7 +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
+ import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } 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;
@@ -106,9 +106,9 @@ function assertPositiveInteger(name, value) {
106
106
  * behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
107
107
  * the right call.
108
108
  */
109
- function forwardedKey(hops, peerFallback) {
109
+ function forwardedKey(hops, peerFallback, trustedPeers) {
110
110
  return (ctx) => {
111
- const forwarded = resolveForwardedClientIp(ctx.request, hops);
111
+ const forwarded = resolveForwardedClientIp(ctx.request, hops, trustedPeers);
112
112
  if (forwarded !== undefined)
113
113
  return forwarded;
114
114
  if (!peerFallback)
@@ -123,7 +123,8 @@ function forwardedKey(hops, peerFallback) {
123
123
  * offenders; bans grow exponentially for persistent abuse and decay once the
124
124
  * client goes quiet.
125
125
  *
126
- * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
126
+ * Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator},
127
+ * {@link AutoBanOptions.trustedHops}, {@link AutoBanOptions.trustedProxies}, or
127
128
  * set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
128
129
  * a misconfiguration can never collapse every caller into one shared bucket and
129
130
  * ban the whole world at once. When the default generator cannot resolve a
@@ -143,8 +144,8 @@ function forwardedKey(hops, peerFallback) {
143
144
  *
144
145
  * @param opts - Auto-ban configuration.
145
146
  * @returns A {@link Hooks} bundle ready for `app.use(...)`.
146
- * @throws Error when neither `keyGenerator` nor `trustProxyHeaders` is provided,
147
- * or when a numeric option is out of range.
147
+ * @throws Error when none of `keyGenerator`, `trustedHops`, `trustedProxies`, or
148
+ * `trustProxyHeaders` is provided, or when a numeric option is out of range.
148
149
  * @since 0.37.0
149
150
  */
150
151
  export function autoBan(opts = {}) {
@@ -177,6 +178,7 @@ export function autoBan(opts = {}) {
177
178
  }
178
179
  const watch = new Set(watchStatuses);
179
180
  const hops = resolveForwardedTrust("autoBan()", opts);
181
+ const proxyMatchers = resolveTrustedProxyMatchers("autoBan()", opts);
180
182
  const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
181
183
  if (onUnresolved !== "peer" && onUnresolved !== "skip") {
182
184
  throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
@@ -186,10 +188,10 @@ export function autoBan(opts = {}) {
186
188
  keyOf = opts.keyGenerator;
187
189
  }
188
190
  else if (hops !== undefined) {
189
- keyOf = forwardedKey(hops, onUnresolved === "peer");
191
+ keyOf = forwardedKey(hops, onUnresolved === "peer", proxyMatchers);
190
192
  }
191
193
  else {
192
- throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
194
+ throw new Error("autoBan(): provide keyGenerator, trustedHops, trustedProxies, or set trustProxyHeaders so clients can be identified; " +
193
195
  "otherwise every caller shares one bucket and a single offender would ban everyone.");
194
196
  }
195
197
  const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
@@ -117,8 +117,8 @@ export interface BotGuardOptions {
117
117
  allowUserAgents?: readonly (string | RegExp)[];
118
118
  /**
119
119
  * Declared-crawler verification rules. When provided, an IP source is
120
- * required (`resolveIp` or `trustProxyHeaders`), otherwise construction
121
- * throws.
120
+ * required (`resolveIp`, `trustedHops`, `trustedProxies`, or
121
+ * `trustProxyHeaders`), otherwise construction throws.
122
122
  */
123
123
  verifiedBots?: readonly VerifiedBotRule[];
124
124
  /**
@@ -147,6 +147,16 @@ export interface BotGuardOptions {
147
147
  * an integer in [1, 64]; validated at construction.
148
148
  */
149
149
  trustedHops?: number;
150
+ /**
151
+ * Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
152
+ * peer's address. Forwarded headers are honoured only when the TCP socket
153
+ * actually talking to the adapter matches the list, so a direct-to-origin
154
+ * attacker cannot impersonate a verified crawler's IP. Implies
155
+ * proxy-header trust at one hop unless {@link trustedHops} says otherwise;
156
+ * validated and compiled at construction. On peer-less edge platforms
157
+ * verification fails closed.
158
+ */
159
+ trustedProxies?: readonly string[];
150
160
  /**
151
161
  * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
152
162
  */
package/dist/bot-guard.js CHANGED
@@ -36,7 +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
+ import { resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
40
40
  const DEFAULT_MESSAGE = "Bot access denied";
41
41
  const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
42
42
  const DEFAULT_CACHE_MAX = 10_000;
@@ -94,8 +94,8 @@ function matchesUserAgent(ua, patterns) {
94
94
  * `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
95
95
  * of the header; see {@link resolveForwardedClientIp}.
96
96
  */
97
- function forwardedIpResolver(hops) {
98
- return (ctx) => resolveForwardedClientIp(ctx.request, hops);
97
+ function forwardedIpResolver(hops, trustedPeers) {
98
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops, trustedPeers);
99
99
  }
100
100
  function noIpResolver(_ctx) {
101
101
  return undefined;
@@ -210,10 +210,12 @@ export function botGuard(opts = {}) {
210
210
  throw new Error('botGuard(): mode must be "block" or "log".');
211
211
  }
212
212
  const hops = resolveForwardedTrust("botGuard()", opts);
213
- const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
213
+ const proxyMatchers = resolveTrustedProxyMatchers("botGuard()", opts);
214
+ const resolveIp = opts.resolveIp ??
215
+ (hops !== undefined ? forwardedIpResolver(hops, proxyMatchers) : noIpResolver);
214
216
  if (verifiedBots.length > 0 && !opts.resolveIp && hops === undefined) {
215
217
  throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp, trustedHops, " +
216
- "or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
218
+ "trustedProxies, or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
217
219
  }
218
220
  const resolver = opts.resolver ?? createDefaultResolver();
219
221
  // Per-IP verification cache (keyed by `ip\u0000botName`) so a crawler's DNS
@@ -113,6 +113,16 @@ export interface ConcurrencyLimitOptions {
113
113
  * in [1, 64]; validated at construction.
114
114
  */
115
115
  trustedHops?: number;
116
+ /**
117
+ * Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
118
+ * peer's address when `scope: "client"`. Forwarded headers feed the bucket
119
+ * key only when the TCP socket actually talking to the adapter matches the
120
+ * list, so a direct-to-origin attacker cannot hop buckets with spoofed XFF
121
+ * to hoard slots. Implies proxy-header trust at one hop unless
122
+ * {@link trustedHops} says otherwise; validated and compiled at
123
+ * construction. On peer-less edge platforms verification fails closed.
124
+ */
125
+ trustedProxies?: readonly string[];
116
126
  /**
117
127
  * Custom client-identity resolver for `scope: "client"`. Overrides
118
128
  * {@link trustProxyHeaders}. Returning `undefined` skips limiting for the
@@ -46,7 +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
+ import { getConnInfo, resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
50
50
  const DEFAULT_MESSAGE = "Concurrency limit exceeded";
51
51
  /** Monotonic id so multiple mounted limiters use distinct per-request state slots. */
52
52
  let instanceCounter = 0;
@@ -64,9 +64,17 @@ function assertNonNegativeInteger(name, value) {
64
64
  * @internal Default identity resolver: the client IP `hops` entries from the
65
65
  * right of `X-Forwarded-For` (falling back to `X-Real-IP`) — the
66
66
  * spoof-resistant side of the header; see {@link resolveForwardedClientIp}.
67
+ * When no trustworthy forwarded identity exists, falls back to the unspoofable
68
+ * TCP peer so a missing/spoofed XFF cannot skip the limit entirely (undefined
69
+ * would fail open). Peer-less platforms still return `undefined` (skip).
67
70
  */
68
- function forwardedKey(hops) {
69
- return (ctx) => resolveForwardedClientIp(ctx.request, hops);
71
+ function forwardedKey(hops, trustedPeers) {
72
+ return (ctx) => {
73
+ const forwarded = resolveForwardedClientIp(ctx.request, hops, trustedPeers);
74
+ if (forwarded !== undefined)
75
+ return forwarded;
76
+ return getConnInfo(ctx.request)?.remoteAddress;
77
+ };
70
78
  }
71
79
  /** Extract just the pathname from a request URL without a full `URL` parse where possible. */
72
80
  function pathnameOf(url) {
@@ -107,16 +115,17 @@ function buildScopeResolver(opts) {
107
115
  }
108
116
  // scope === "client"
109
117
  const hops = resolveForwardedTrust("concurrencyLimit()", opts);
118
+ const proxyMatchers = resolveTrustedProxyMatchers("concurrencyLimit()", opts);
110
119
  let resolve;
111
120
  if (opts.keyGenerator) {
112
121
  resolve = opts.keyGenerator;
113
122
  }
114
123
  else if (hops !== undefined) {
115
- resolve = forwardedKey(hops);
124
+ resolve = forwardedKey(hops, proxyMatchers);
116
125
  }
117
126
  else {
118
- throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, or trustProxyHeaders so ' +
119
- "clients can be identified; otherwise every caller shares one bucket.");
127
+ throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, trustedProxies, or ' +
128
+ "trustProxyHeaders so clients can be identified; otherwise every caller shares one bucket.");
120
129
  }
121
130
  return (ctx) => {
122
131
  const id = resolve(ctx);
@@ -19,6 +19,7 @@
19
19
  * @since 0.24.0
20
20
  */
21
21
  import type { BaseContext } from "./types.js";
22
+ import { type IpMatcher } from "./ip-match.js";
22
23
  /**
23
24
  * Declarative reverse-proxy posture. Replaces the
24
25
  * foot-gunny `trustProxy: boolean` with a structured value that
@@ -127,23 +128,57 @@ export declare function pickForwardedForByHops(header: string | null, hops: numb
127
128
  * `behindProxy.hops` range: the floor of one exists because a middleware that
128
129
  * trusts zero proxy hops has no business reading forwarding headers at all.
129
130
  *
131
+ * `trustedProxies` (a CIDR allowlist of proxy peer addresses) also enables
132
+ * forwarded-header trust, defaulting to one hop when `trustedHops` is not
133
+ * set: declaring WHO your proxies are is meaningless unless their headers
134
+ * are then read. Pair it with {@link resolveTrustedProxyMatchers} and pass
135
+ * the compiled matchers to {@link resolveForwardedClientIp} so the forwarded
136
+ * identity is honoured only when the immediate TCP peer is a verified proxy.
137
+ *
130
138
  * @param name - Middleware function name used in error messages.
131
- * @param opts - The middleware's options object; only the two trust fields are
139
+ * @param opts - The middleware's options object; only the trust fields are
132
140
  * read, so any middleware option type is structurally acceptable.
133
141
  * @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.
142
+ * enabled — `trustedHops` verbatim, `1` for a bare
143
+ * `trustProxyHeaders: true`, or `1` when `trustedProxies` is declared
144
+ * without an explicit hop count or `undefined` when trust is off and the
145
+ * caller must not read forwarding headers at all.
137
146
  * @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.
147
+ * `trustProxyHeaders: false` is combined with a `trustedHops` value or a
148
+ * `trustedProxies` list. Those pairings are contradictions, and the first
149
+ * previously resolved silently in favour of trust — meaning an explicit
150
+ * opt-out was ignored.
141
151
  * @internal
142
152
  */
143
153
  export declare function resolveForwardedTrust(name: string, opts: {
144
154
  trustedHops?: number;
145
155
  trustProxyHeaders?: boolean;
156
+ trustedProxies?: readonly string[];
146
157
  }): number | undefined;
158
+ /**
159
+ * Validate and compile a middleware's `trustedProxies` CIDR allowlist into
160
+ * matchers usable with {@link resolveForwardedClientIp}. Called once at
161
+ * construction so the per-request cost of peer verification is a handful of
162
+ * byte comparisons, never string parsing.
163
+ *
164
+ * The allowlist answers the question `trustedHops` alone cannot: not "how
165
+ * many proxies are in front of me" but "is the socket actually talking to me
166
+ * one of MY proxies". Without it, any client that can reach the origin
167
+ * directly can claim any `X-Forwarded-For` identity — the victim-IP framing
168
+ * and ban-evasion classes documented on {@link resolveForwardedClientIp}.
169
+ *
170
+ * @param name - Middleware function name used in error messages.
171
+ * @param opts - The middleware's options object; only `trustedProxies` is read.
172
+ * @returns The compiled matchers, or `undefined` when `trustedProxies` is not
173
+ * declared (no peer verification — the pre-existing posture).
174
+ * @throws Error when the list is empty (a silent "trust nobody" foot-gun) or
175
+ * any entry is not a valid IP/CIDR. Both are refuse-at-construction
176
+ * misconfigurations, never request-time surprises.
177
+ * @internal
178
+ */
179
+ export declare function resolveTrustedProxyMatchers(name: string, opts: {
180
+ trustedProxies?: readonly string[];
181
+ }): IpMatcher[] | undefined;
147
182
  /**
148
183
  * Resolve the client IP from the proxy-set forwarding headers, walking a
149
184
  * declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
@@ -165,17 +200,27 @@ export declare function resolveForwardedTrust(name: string, opts: {
165
200
  * Security note: this resolver is only meaningful when every request reaches
166
201
  * the app through a proxy chain you control that appends (or overwrites)
167
202
  * these headers. With no proxy in front, any forwarded-header trust is
168
- * attacker-controlled by definition.
203
+ * attacker-controlled by definition. `trustedPeers` closes that gap at the
204
+ * framework layer: when supplied, the forwarded identity is honoured only
205
+ * when the immediate TCP peer — the socket actually talking to the adapter,
206
+ * which a remote client cannot spoof — is inside the declared proxy
207
+ * allowlist. A direct-to-origin attacker then gets `undefined` (no
208
+ * identity), so spoofed headers can neither frame a victim nor rotate away
209
+ * strikes. When conn metadata is absent (peer-less edge platforms),
210
+ * verification fails closed.
169
211
  *
170
212
  * @param request - Incoming request whose forwarding headers are read.
171
213
  * @param hops - Number of trusted proxy hops; `1` (default) reads the
172
214
  * rightmost entry — the one your immediate proxy appended.
215
+ * @param trustedPeers - Optional compiled allowlist from
216
+ * {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
217
+ * are honoured only if the immediate peer matches; otherwise `undefined`.
173
218
  * @returns The resolved client IP, or `undefined` when no forwarded identity
174
219
  * is available. Callers decide their own posture for `undefined`
175
220
  * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
176
221
  * @since 1.0.0-rc.7
177
222
  */
178
- export declare function resolveForwardedClientIp(request: Request, hops?: number): string | undefined;
223
+ export declare function resolveForwardedClientIp(request: Request, hops?: number, trustedPeers?: readonly IpMatcher[]): string | undefined;
179
224
  /**
180
225
  * Resolve the client IP for this request using the configured
181
226
  * {@link BehindProxyConfig}. Returns `undefined` when no trusted source is