@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 +3 -2
- package/dist/auto-ban.d.ts +16 -0
- package/dist/auto-ban.js +19 -9
- package/dist/bot-guard.d.ts +14 -0
- package/dist/bot-guard.js +12 -12
- 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 +15 -0
- package/dist/geo-block.js +11 -11
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/ip-reputation.d.ts +14 -0
- package/dist/ip-reputation.js +10 -10
- package/dist/ip-restriction.d.ts +14 -0
- package/dist/ip-restriction.js +5 -7
- package/dist/mcp.d.ts +305 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
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/auto-ban.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
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
|
-
|
|
150
|
-
|
|
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) {
|
package/dist/bot-guard.d.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
|
@@ -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
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/dist/geo-block.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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";
|
package/dist/ip-reputation.d.ts
CHANGED
|
@@ -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
|
package/dist/ip-reputation.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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: [] }));
|