@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 +3 -2
- package/dist/adapters/node.js +9 -2
- package/dist/auto-ban.d.ts +65 -5
- package/dist/auto-ban.js +114 -29
- package/dist/bot-guard.d.ts +16 -2
- package/dist/bot-guard.js +18 -13
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/geo-block.d.ts +18 -3
- package/dist/geo-block.js +18 -12
- package/dist/idempotency.d.ts +56 -2
- package/dist/idempotency.js +135 -6
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/ip-reputation.d.ts +16 -2
- package/dist/ip-reputation.js +16 -11
- package/dist/ip-restriction.d.ts +17 -3
- package/dist/ip-restriction.js +12 -9
- package/dist/mcp.d.ts +297 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- package/dist/safe-redirect.js +19 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/types.d.ts +31 -0
- package/dist/waf.js +38 -2
- package/dist/websocket.d.ts +48 -5
- package/dist/websocket.js +57 -5
- package/package.json +6 -4
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
|
-
-
|
|
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.
|
package/dist/adapters/node.js
CHANGED
|
@@ -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,
|
|
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);
|
package/dist/auto-ban.d.ts
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.
|
|
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 {
|
|
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:
|
|
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.
|
|
189
|
-
*
|
|
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.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
104
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|
package/dist/bot-guard.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
* @since 0.37.0
|
|
37
37
|
*/
|
|
38
|
-
import type {
|
|
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:
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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}`;
|
package/dist/conn-info.d.ts
CHANGED
|
@@ -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
|