@daloyjs/core 1.0.0-rc.5 → 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.
Files changed (51) hide show
  1. package/README.md +25 -14
  2. package/dist/adapters/bun.js +1 -2
  3. package/dist/adapters/node.js +16 -30
  4. package/dist/app.d.ts +5 -1
  5. package/dist/app.js +74 -6
  6. package/dist/auto-ban.d.ts +16 -0
  7. package/dist/auto-ban.js +20 -12
  8. package/dist/bot-guard.d.ts +14 -0
  9. package/dist/bot-guard.js +12 -12
  10. package/dist/cli.js +9 -6
  11. package/dist/concurrency-limit.d.ts +14 -0
  12. package/dist/concurrency-limit.js +18 -9
  13. package/dist/config.js +1 -3
  14. package/dist/conn-info.d.ts +65 -0
  15. package/dist/conn-info.js +99 -4
  16. package/dist/errors.js +2 -5
  17. package/dist/etag.js +12 -2
  18. package/dist/geo-block.d.ts +15 -0
  19. package/dist/geo-block.js +14 -19
  20. package/dist/hashing.js +1 -1
  21. package/dist/http-signatures.js +3 -8
  22. package/dist/index.d.ts +5 -5
  23. package/dist/index.js +4 -4
  24. package/dist/ip-reputation.d.ts +14 -0
  25. package/dist/ip-reputation.js +11 -11
  26. package/dist/ip-restriction.d.ts +14 -0
  27. package/dist/ip-restriction.js +7 -18
  28. package/dist/jwt.js +12 -14
  29. package/dist/logger.js +1 -3
  30. package/dist/mcp.d.ts +305 -34
  31. package/dist/mcp.js +554 -49
  32. package/dist/middleware.d.ts +31 -1
  33. package/dist/middleware.js +21 -19
  34. package/dist/multipart.js +9 -12
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.js +2 -2
  37. package/dist/rate-limit-redis.d.ts +4 -4
  38. package/dist/response-cache.d.ts +179 -21
  39. package/dist/response-cache.js +338 -29
  40. package/dist/safe-redirect.js +3 -1
  41. package/dist/sbom.cdx.json +9 -9
  42. package/dist/sbom.spdx.json +5 -5
  43. package/dist/security-schemes.js +1 -2
  44. package/dist/subdomains.js +1 -4
  45. package/dist/tenancy.d.ts +40 -0
  46. package/dist/tenancy.js +54 -3
  47. package/dist/waf.js +40 -8
  48. package/dist/webhook-delivery.js +19 -3
  49. package/dist/websocket.d.ts +8 -0
  50. package/dist/websocket.js +19 -4
  51. package/package.json +2 -2
package/README.md CHANGED
@@ -28,11 +28,21 @@ Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yello
28
28
 
29
29
  DaloyJS is maintained in the GitHub organization at <https://github.com/daloyjs>; the canonical framework repository is <https://github.com/daloyjs/daloy>.
30
30
 
31
- ## Partners
31
+ ## Acknowledgements
32
32
 
33
- <a href="https://snyk.io">
34
- <img src="https://github.com/user-attachments/assets/da58db43-67cc-45d4-ade5-bdaa7b041465" alt="Snyk's Secure Developer Program" width="160">
35
- </a>
33
+ We are grateful to the following companies for supporting DaloyJS with free access to their tools and services.
34
+
35
+ <div align="center" style="background-color: #f5f5f5; padding: 25px; border-radius: 10px; margin: 20px 0;">
36
+
37
+ <a href="https://snyk.io"><img src="https://github.com/user-attachments/assets/da58db43-67cc-45d4-ade5-bdaa7b041465" height="75" width="auto" alt="Snyk"></a>
38
+    
39
+ <a href="https://socket.dev"><img src="https://github.com/user-attachments/assets/7d2dde1f-6b60-4f20-b05b-80ace5ae6862" height="75" width="auto" alt="Socket"></a>
40
+    
41
+ <a href="https://www.aikido.dev"><img src="https://github.com/user-attachments/assets/67e62dd1-b907-4246-a0aa-95ef13fa491c" height="75" width="auto" alt="Aikido"></a>
42
+    
43
+ <a href="https://www.coderabbit.ai"><img src="https://github.com/user-attachments/assets/0d9d8e68-eb21-41ec-978c-337b66ee34b6" height="75" width="auto" alt="CodeRabbit"></a>
44
+
45
+ </div>
36
46
 
37
47
  ---
38
48
 
@@ -66,7 +76,7 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
66
76
  | **Portable supply-chain hardening** for the apps you build | [pnpm](https://pnpm.io/motivation) defaults + a zero-runtime-dep core | Hardened `.npmrc`, source-verified lockfiles, zero runtime deps, CycloneDX + SPDX SBOM, and npm provenance attestations. |
67
77
 
68
78
  ```
69
- framework test suite passing · ≥90% line + function coverage / ≥90% branch coverage · typechecks on TypeScript 7 with `strict: true`
79
+ framework test suite passing · ≥90% line + function coverage / ≥92% branch coverage · typechecks on TypeScript 7 with `strict: true`
70
80
  runs on Node, Bun, Deno, Cloudflare, Vercel
71
81
  ~12.3M static-route ops/sec · ~1.5M dynamic-route ops/sec on M-class CPU
72
82
  ```
@@ -196,7 +206,7 @@ app.get(
196
206
  async ({ params }) => ({
197
207
  status: 200,
198
208
  body: { id: params.id, title: `Book ${params.id}` },
199
- }),
209
+ })
200
210
  );
201
211
 
202
212
  serve(app, { port: 3000 });
@@ -529,7 +539,7 @@ const usersPlugin = {
529
539
  operationId: "me",
530
540
  responses: { 200: { description: "ok" } },
531
541
  },
532
- async () => ({ status: 200, body: { user: "alice" } }),
542
+ async () => ({ status: 200, body: { user: "alice" } })
533
543
  );
534
544
  },
535
545
  };
@@ -574,9 +584,9 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
574
584
 
575
585
  ## Status
576
586
 
577
- DaloyJS is at **`1.0.0-rc.5`**, a security-hardening release candidate. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before the stable release rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` stable onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
587
+ DaloyJS is at **`1.0.0-rc.6`**, a security-hardening release candidate carrying the remediations from a live over-the-wire engagement against a realistic multi-tenant app — cross-principal response-cache disclosure (`SECURITY-AUDIT.md` F-4 … F-9), SQLi signature evasions in `waf()`, and an unbounded WebSocket frame buffer. Because the framework has no external users yet, this RC makes a few intentional changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before the stable release rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` stable onward, the API follows SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
578
588
 
579
- **Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
589
+ **Release quality bar.** Every release ships with **≥90% line + function coverage** (`pnpm coverage`) **and ≥92% branch coverage** (`pnpm coverage:branches` on compiled JS), strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
580
590
 
581
591
  ### Routing, validation, and docs
582
592
 
@@ -588,7 +598,8 @@ DaloyJS is at **`1.0.0-rc.5`**, a security-hardening release candidate. Because
588
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.
589
599
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
590
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`.
591
- - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
601
+ - Project docs site serves a curated [`/llms.txt`](https://daloyjs.dev/llms.txt) index (Markdown map + `.md` siblings of every docs page, blog under `Optional`) so coding agents can load documentation without scraping HTML; see [llms.txt docs](https://daloyjs.dev/docs/llms-txt).
602
+ - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`, speaking the **stateless MCP `2026-07-28`** revision and every earlier one on the same endpoint: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Modern requests get `server/discover`, per-request `_meta` validation, `resultType` + `_meta.serverInfo` on every result, `ttlMs` / `cacheScope` caching hints (defaulting to no caching and `private` scope so an authorization-scoped tool list is never shared by a proxy), multi round-trip requests (`input_required` + client retry, replacing server-initiated elicitation/sampling/roots), and `x-mcp-header` parameter mirroring — with a missing or disagreeing `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` header rejected as `-32020` so a gateway can never route on one value while the tool executes another. Legacy clients keep the `initialize` handshake unchanged. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
592
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.
593
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`.
594
605
 
@@ -646,16 +657,16 @@ The framework refuses to start (or to construct) when configuration is unsafe:
646
657
  - `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge and per-request scope aggregation.
647
658
  - `session()` with signed cookies and pluggable stores.
648
659
  - `idempotency()` with `Idempotency-Key` fingerprinting + byte-for-byte response replay, in-flight `409`, `422` on key reuse with a different payload, and a pluggable `IdempotencyStore` (in-memory default) at `@daloyjs/core/idempotency`.
649
- - `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, `Vary`-aware keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` in-memory default) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie` or `private`/`no-store`/`no-cache` responses. Complements `etag()`/`compression()`, which do not cache bodies.
660
+ - `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, proactive `varyHeaders` keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` whose in-memory default is bounded on both entry count and retained bytes) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie`, `private`/`no-store`/`no-cache`, or `Vary: *` responses, and strips `Age`/hop-by-hop/`X-Request-Id` from stored entries so a hit never replays another request's correlation id. **Fail-closed on every principal dimension (CWE-524):** the key is the full _effective request URI_ including the authority (RFC 9111 §4), so hostnames never share entries; requests carrying `Authorization` **or** `Cookie` bypass the shared cache unless a `principal` names the caller (then each gets its own entry) or the header is explicitly declared shareable; a tenant resolved by `tenancy()` is folded into the key automatically — with a boot guard that refuses to start if the cache is mounted ahead of `tenancy()`; and the response's **own `Vary` header** is honoured as a secondary key (RFC 9111 §4.1), so the `Vary: Origin` written by `cors()` and the `Vary: Accept-Encoding` written by `compression()` keep one caller's allowed origin — or their gzipped bytes — from being served to the next, with each variant stored separately so they all stay warm. Complements `etag()`/`compression()`, which do not cache bodies.
650
661
  - `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
651
662
  - `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
652
663
  - `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
653
- - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` / `responseCache` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense). Runnable `examples/multitenancy-demo.ts`.
664
+ - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense); `responseCache()` needs no wiring at all — it reads the resolved tenant itself and refuses to boot if mounted ahead of `tenancy()`. Runnable `examples/multitenancy-demo.ts`.
654
665
  - `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
655
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.
656
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.
657
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.
658
- - `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.
659
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.
660
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.
661
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.
@@ -697,7 +708,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
697
708
  - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort` — populated by the Node, Bun, Deno, and Lambda adapters from the real peer socket / event source, never from spoofable headers.
698
709
  - `daloy doctor` production-posture validator with `--audit-secrets` and `--audit-defaults` (flags wildcard-credentials CORS, > 24h CORS `maxAge`, > 25 MiB blanket body limits, zero `idleTimeoutMs` in production, and unsafe opt-ins).
699
710
  - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
700
- - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, and a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` / `responseCache` per tenant.
711
+ - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` per tenant, and automatic per-tenant `responseCache` partitioning backed by a boot guard.
701
712
  - `defineDependency()` typed-DI helper with per-request deduplication.
702
713
  - Scheme-aware `ctx.state.auth` typed contract; named, optionally seeded stateful plugins.
703
714
 
@@ -273,8 +273,7 @@ function reportBunHandlerFailure(app, data, label, err, notifyError) {
273
273
  }
274
274
  }
275
275
  function validateControlPayload(data) {
276
- if (data !== undefined &&
277
- encodeSendPayload(data).payload.length > WS_MAX_CONTROL_PAYLOAD) {
276
+ if (data !== undefined && encodeSendPayload(data).payload.length > WS_MAX_CONTROL_PAYLOAD) {
278
277
  throw new WebSocketProtocolError("Control frame payload exceeds 125 bytes");
279
278
  }
280
279
  }
@@ -2,7 +2,7 @@
2
2
  * Node adapter: translates IncomingMessage/ServerResponse to web-standard
3
3
  * Request/Response. Includes graceful shutdown wired to SIGTERM/SIGINT.
4
4
  */
5
- import { createServer, } from "node:http";
5
+ import { createServer } from "node:http";
6
6
  import { Readable } from "node:stream";
7
7
  import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
8
8
  import { BadRequestError } from "../errors.js";
@@ -74,9 +74,7 @@ export function serve(app, opts = {}) {
74
74
  // `0` opts out and restores Node's unbounded-ish default (2000).
75
75
  const maxHeaderCount = opts.maxHeaderCount;
76
76
  server.maxHeadersCount =
77
- typeof maxHeaderCount === "number" && maxHeaderCount >= 0
78
- ? maxHeaderCount
79
- : 100;
77
+ typeof maxHeaderCount === "number" && maxHeaderCount >= 0 ? maxHeaderCount : 100;
80
78
  // Connection-layer admission control. Reject overflow sockets at accept time
81
79
  // rather than queuing them into the event loop under overload.
82
80
  if (typeof opts.maxConnections === "number" && opts.maxConnections > 0) {
@@ -125,7 +123,10 @@ export function serve(app, opts = {}) {
125
123
  await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
126
124
  };
127
125
  if (opts.handleSignals !== false) {
128
- const onSignal = (sig) => { app.log.info({ sig }, "DaloyJS received signal, shutting down"); void close().then(() => process.exit(0)); };
126
+ const onSignal = (sig) => {
127
+ app.log.info({ sig }, "DaloyJS received signal, shutting down");
128
+ void close().then(() => process.exit(0));
129
+ };
129
130
  process.once("SIGTERM", () => onSignal("SIGTERM"));
130
131
  process.once("SIGINT", () => onSignal("SIGINT"));
131
132
  }
@@ -133,9 +134,7 @@ export function serve(app, opts = {}) {
133
134
  server,
134
135
  get port() {
135
136
  const address = server.address();
136
- return address !== null && typeof address === "object"
137
- ? address.port
138
- : requestedPort;
137
+ return address !== null && typeof address === "object" ? address.port : requestedPort;
139
138
  },
140
139
  close,
141
140
  };
@@ -280,11 +279,7 @@ function attachClientCertificate(req, request) {
280
279
  * Node's `connect` event rather than the request listener; it is included here
281
280
  * defensively for runtimes/proxies that surface it as a normal request.)
282
281
  */
283
- const FETCH_FORBIDDEN_METHODS = new Set([
284
- "CONNECT",
285
- "TRACE",
286
- "TRACK",
287
- ]);
282
+ const FETCH_FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
288
283
  /**
289
284
  * Refuse a Fetch-forbidden HTTP method with a spec-correct `501 Not
290
285
  * Implemented`. `501` is more accurate than `405` here because the method is
@@ -544,15 +539,10 @@ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
544
539
  LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
545
540
  function toWebRequest(req, trustProxy, bufferedBody) {
546
541
  const reqHeaders = req.headers;
547
- const forwardedHost = trustProxy
548
- ? firstHeader(reqHeaders["x-forwarded-host"])
549
- : undefined;
542
+ const forwardedHost = trustProxy ? firstHeader(reqHeaders["x-forwarded-host"]) : undefined;
550
543
  const host = forwardedHost ?? reqHeaders.host ?? "localhost";
551
- const forwardedProto = trustProxy
552
- ? firstHeader(reqHeaders["x-forwarded-proto"])
553
- : undefined;
554
- const proto = forwardedProto ??
555
- (req.socket.encrypted ? "https" : "http");
544
+ const forwardedProto = trustProxy ? firstHeader(reqHeaders["x-forwarded-proto"]) : undefined;
545
+ const proto = forwardedProto ?? (req.socket.encrypted ? "https" : "http");
556
546
  const url = `${proto}://${host}${normalizeRequestTarget(req.url)}`;
557
547
  // Reject malformed Host / request-target combinations at the adapter
558
548
  // boundary instead of letting the invalid URL propagate as a 500 later.
@@ -667,7 +657,8 @@ function sendWebResponse(res, out) {
667
657
  // which we must strip so Node falls back to chunked transfer-encoding.
668
658
  const rawStream = res[DALOY_RAW_STREAM];
669
659
  if (rawStream !== undefined) {
670
- if (typeof rawStream.pipe === "function" && !(rawStream instanceof ReadableStream)) {
660
+ if (typeof rawStream.pipe === "function" &&
661
+ !(rawStream instanceof ReadableStream)) {
671
662
  // Node `Readable` from the handler: skip the Web-stream bridge entirely
672
663
  // and `.pipe(out)` like Fastify/Koa/Express do.
673
664
  out.removeHeader("content-length");
@@ -721,15 +712,10 @@ function pumpBody(body, out) {
721
712
  }
722
713
  // ---------- WebSocket upgrade ----------
723
714
  async function handleUpgrade(app, req, socket, head, trustProxy) {
724
- const forwardedHost = trustProxy
725
- ? firstHeader(req.headers["x-forwarded-host"])
726
- : undefined;
715
+ const forwardedHost = trustProxy ? firstHeader(req.headers["x-forwarded-host"]) : undefined;
727
716
  const host = forwardedHost ?? req.headers.host ?? "localhost";
728
- const forwardedProto = trustProxy
729
- ? firstHeader(req.headers["x-forwarded-proto"])
730
- : undefined;
731
- const proto = forwardedProto ??
732
- (req.socket.encrypted ? "https" : "http");
717
+ const forwardedProto = trustProxy ? firstHeader(req.headers["x-forwarded-proto"]) : undefined;
718
+ const proto = forwardedProto ?? (req.socket.encrypted ? "https" : "http");
733
719
  // A malformed `Host` header (e.g. containing a space) reaches this point:
734
720
  // Node's HTTP parser accepts it and fires `upgrade`, but WHATWG URL
735
721
  // parsing throws. Reject it as the client error it is instead of letting
package/dist/app.d.ts CHANGED
@@ -1176,7 +1176,11 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1176
1176
  * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
1177
1177
  * MCP tools are model-controlled and side-effecting, so a public one is a
1178
1178
  * high-impact default.
1179
- * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
1179
+ * 3. **Cache ahead of tenancy** — a `responseCache()` that runs before
1180
+ * `tenancy()` builds its key before the tenant exists in `ctx.state`, so
1181
+ * every tenant collides on one entry and one tenant's response is served to
1182
+ * the next caller (CWE-524).
1183
+ * 4. **Missing CSRF** — when `session()` is installed and any route accepts a
1180
1184
  * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
1181
1185
  * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
1182
1186
  * also be present. Skipped when `app({ csrf: "off" })`.
package/dist/app.js CHANGED
@@ -127,6 +127,15 @@ const CANONICAL_HTTP_METHODS = new Set([
127
127
  * `App` bundle. Must match the string used in `mcpRoutes`.
128
128
  */
129
129
  const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
130
+ /**
131
+ * Global-registry symbols stamped by `responseCache()` and `tenancy()` on the
132
+ * `Hooks` bundles they return. Read here — rather than imported from
133
+ * `response-cache.ts` / `tenancy.ts` — so neither module is pulled into the core
134
+ * `App` bundle (which would cost every serverless cold start). Must match the
135
+ * strings used in those modules.
136
+ */
137
+ const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
138
+ const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
130
139
  /**
131
140
  * Apply a topology-aware security preset on top of caller-supplied
132
141
  * options. Returns a new options object where preset defaults fill in
@@ -949,7 +958,11 @@ export class App {
949
958
  * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
950
959
  * MCP tools are model-controlled and side-effecting, so a public one is a
951
960
  * high-impact default.
952
- * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
961
+ * 3. **Cache ahead of tenancy** — a `responseCache()` that runs before
962
+ * `tenancy()` builds its key before the tenant exists in `ctx.state`, so
963
+ * every tenant collides on one entry and one tenant's response is served to
964
+ * the next caller (CWE-524).
965
+ * 4. **Missing CSRF** — when `session()` is installed and any route accepts a
953
966
  * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
954
967
  * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
955
968
  * also be present. Skipped when `app({ csrf: "off" })`.
@@ -998,7 +1011,24 @@ export class App {
998
1011
  this.bootGuard.error = err;
999
1012
  throw err;
1000
1013
  }
1001
- // Guard 3: session() + state-changing route without csrf().
1014
+ // Guard 3: responseCache() mounted ahead of tenancy(). The cache partitions
1015
+ // on the tenant automatically, but only if the tenant is already in
1016
+ // ctx.state when the key is built. Mounted first, it would key every
1017
+ // tenant's response identically and serve one tenant's private body to the
1018
+ // next caller (CWE-524) — silently, with a normal-looking cache HIT.
1019
+ const cacheBeforeTenancy = this.routeSecurityMarkers.find((r) => r.cacheBeforeTenancy);
1020
+ if (cacheBeforeTenancy) {
1021
+ const err = new Error(`Route ${cacheBeforeTenancy.method} ${cacheBeforeTenancy.path} runs responseCache() ` +
1022
+ `before tenancy() in its effective hook chain. The cache key is built before the tenant ` +
1023
+ `is resolved, so every tenant would share one cache entry and one tenant's response ` +
1024
+ `would be served to the next caller (CWE-524 cross-tenant cached-response disclosure). ` +
1025
+ `Register tenancy() first — as a global hook (new App({ hooks: tenancy(...) })) or an ` +
1026
+ `earlier app.use(...) — so the tenant is in ctx.state before the cache reads it. ` +
1027
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
1028
+ this.bootGuard.error = err;
1029
+ throw err;
1030
+ }
1031
+ // Guard 4: session() + state-changing route without csrf().
1002
1032
  if (this.options.csrf === "off")
1003
1033
  return;
1004
1034
  const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
@@ -1068,13 +1098,21 @@ export class App {
1068
1098
  this.trustProxyWarned = true;
1069
1099
  this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
1070
1100
  }
1071
- throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
1101
+ const refusal = new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
1072
1102
  `Honouring a spoofable forwarded header would let a client forge its source IP for the rate ` +
1073
1103
  `limiter, audit log, and request-id propagation. ` +
1074
1104
  `Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
1075
1105
  `or app({ trustProxy: false }) to ignore forwarded headers, ` +
1076
1106
  `or app({ secureDefaults: false }) to disable this guard. ` +
1077
1107
  `See https://daloyjs.dev/docs/security/boot-guards.`);
1108
+ // Every refused request throws from this one line, so the stack is
1109
+ // identical each time and names framework internals rather than anything
1110
+ // an operator can act on. Keep the 500 and keep a line per request — the
1111
+ // refusal must stay visible — but drop the stack, so a client cannot
1112
+ // multiply the bytes it pushes into the error tier by replaying the header.
1113
+ // The actionable message is logged once per process by the warn above.
1114
+ refusal[OMIT_STACK_IN_LOG] = true;
1115
+ throw refusal;
1078
1116
  }
1079
1117
  /**
1080
1118
  * Resolve the {@link AppOptions.docs} option and, when enabled, register
@@ -3143,16 +3181,29 @@ function securityMarkersFromHooks(layers) {
3143
3181
  let hasSession = false;
3144
3182
  let hasCsrf = false;
3145
3183
  let hasAuth = false;
3146
- for (const hooks of layers) {
3147
- const record = hooks;
3184
+ // `layers` is in execution order, so the first index of each marker is enough
3185
+ // to tell whether the cache reads state before tenancy has written it.
3186
+ let cacheIndex = -1;
3187
+ let tenancyIndex = -1;
3188
+ for (let i = 0; i < layers.length; i++) {
3189
+ const record = layers[i];
3148
3190
  if (record[SESSION_HOOK_MARKER] === true)
3149
3191
  hasSession = true;
3150
3192
  if (record[CSRF_HOOK_MARKER] === true)
3151
3193
  hasCsrf = true;
3152
3194
  if (record[AUTH_HOOK_MARKER] === true)
3153
3195
  hasAuth = true;
3196
+ if (cacheIndex === -1 && record[RESPONSE_CACHE_HOOK_MARKER] === true)
3197
+ cacheIndex = i;
3198
+ if (tenancyIndex === -1 && record[TENANCY_HOOK_MARKER] === true)
3199
+ tenancyIndex = i;
3154
3200
  }
3155
- return { hasSession, hasCsrf, hasAuth };
3201
+ return {
3202
+ hasSession,
3203
+ hasCsrf,
3204
+ hasAuth,
3205
+ cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
3206
+ };
3156
3207
  }
3157
3208
  function isStateChangingMethod(method) {
3158
3209
  return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
@@ -4079,8 +4130,25 @@ function withTimeout(p, ms, request) {
4079
4130
  });
4080
4131
  });
4081
4132
  }
4133
+ /**
4134
+ * Marker set on an error whose stack carries no incident information because
4135
+ * the same framework line throws it for every offending request — a rejected
4136
+ * *configuration* or *request shape*, not a fault in the app's code.
4137
+ *
4138
+ * `serializeErr` omits the stack for these. The stack is byte-identical on every
4139
+ * occurrence and points at framework internals, so logging it per request just
4140
+ * multiplies the volume an unauthenticated client can push into the error tier
4141
+ * (the expensive, alerting one) without telling an operator anything the
4142
+ * message does not already say.
4143
+ *
4144
+ * @internal
4145
+ */
4146
+ const OMIT_STACK_IN_LOG = Symbol.for("daloyjs.error.omitStackInLog");
4082
4147
  function serializeErr(err) {
4083
4148
  if (err instanceof Error) {
4149
+ if (err[OMIT_STACK_IN_LOG] === true) {
4150
+ return { name: err.name, message: err.message };
4151
+ }
4084
4152
  return { name: err.name, message: err.message, stack: err.stack };
4085
4153
  }
4086
4154
  return { value: String(err) };
@@ -134,8 +134,24 @@ export interface AutoBanOptions {
134
134
  * Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
135
135
  * default because those headers are client-spoofable unless every request
136
136
  * reaches the app through a proxy chain you control.
137
+ *
138
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
139
+ * one your immediate proxy appended — never the attacker-influenceable
140
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
141
+ * instead so the key comes from the slot your outermost trusted proxy wrote.
137
142
  */
138
143
  trustProxyHeaders?: boolean;
144
+ /**
145
+ * Declare exactly how many proxy hops sit between Daloy and the public
146
+ * internet. Implies proxy-header trust (no need to also set
147
+ * {@link trustProxyHeaders}) and reads the client IP that many entries from
148
+ * the right of `X-Forwarded-For` via
149
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so spoofed entries an
150
+ * attacker prepends on the left are ignored — they can neither evade strike
151
+ * accumulation nor frame a victim IP for banning. Must be an integer in
152
+ * [1, 64]; validated at construction.
153
+ */
154
+ trustedHops?: number;
139
155
  /** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
140
156
  store?: AutoBanStore;
141
157
  /**
package/dist/auto-ban.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * @since 0.37.0
21
21
  */
22
22
  import { ForbiddenError, TooManyRequestsError } from "./errors.js";
23
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
23
24
  const DEFAULT_WINDOW_MS = 10 * 60_000;
24
25
  const DEFAULT_MAX_STRIKES = 5;
25
26
  const DEFAULT_BAN_MS = 15 * 60_000;
@@ -84,12 +85,14 @@ function assertPositiveInteger(name, value) {
84
85
  throw new Error(`autoBan(): ${name} must be a positive integer.`);
85
86
  }
86
87
  }
87
- function forwardedKey(ctx) {
88
- const forwarded = ctx.request.headers.get("x-forwarded-for");
89
- const first = forwarded ? forwarded.split(",")[0].trim() : "";
90
- if (first)
91
- return first;
92
- return ctx.request.headers.get("x-real-ip") ?? undefined;
88
+ /**
89
+ * @internal Default identity resolver: the client IP at the declared number of
90
+ * trusted proxy hops from the right of `X-Forwarded-For` (falling back to
91
+ * `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
92
+ * {@link resolveForwardedClientIp}.
93
+ */
94
+ function forwardedKey(hops) {
95
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
93
96
  }
94
97
  /**
95
98
  * Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
@@ -146,11 +149,18 @@ export function autoBan(opts = {}) {
146
149
  }
147
150
  }
148
151
  const watch = new Set(watchStatuses);
149
- if (!opts.keyGenerator && !opts.trustProxyHeaders) {
150
- throw new Error("autoBan(): provide keyGenerator or set trustProxyHeaders so clients can be identified; " +
152
+ const hops = resolveForwardedTrust("autoBan()", opts);
153
+ let keyOf;
154
+ if (opts.keyGenerator) {
155
+ keyOf = opts.keyGenerator;
156
+ }
157
+ else if (hops !== undefined) {
158
+ keyOf = forwardedKey(hops);
159
+ }
160
+ else {
161
+ throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
151
162
  "otherwise every caller shares one bucket and a single offender would ban everyone.");
152
163
  }
153
- const keyOf = opts.keyGenerator ?? forwardedKey;
154
164
  const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
155
165
  let store;
156
166
  if (opts.store) {
@@ -207,9 +217,7 @@ export function autoBan(opts = {}) {
207
217
  opts.onStrike?.({ key, strikes, status: res.status });
208
218
  if (strikes >= maxStrikes) {
209
219
  banCount += 1;
210
- const duration = escalate
211
- ? Math.min(maxBanMs, banMs * 2 ** (banCount - 1))
212
- : banMs;
220
+ const duration = escalate ? Math.min(maxBanMs, banMs * 2 ** (banCount - 1)) : banMs;
213
221
  bannedUntilMs = now + duration;
214
222
  strikes = 0;
215
223
  opts.onBan?.({ key, banCount, banDurationMs: duration, bannedUntilMs });
@@ -131,8 +131,22 @@ export interface BotGuardOptions {
131
131
  /**
132
132
  * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
133
133
  * enable behind a trusted proxy that overwrites these headers.
134
+ *
135
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
136
+ * entry — the one your immediate proxy appended — never the
137
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
138
+ * {@link trustedHops} instead.
134
139
  */
135
140
  trustProxyHeaders?: boolean;
141
+ /**
142
+ * Declare exactly how many proxy hops sit between Daloy and the public
143
+ * internet. Implies proxy-header trust and reads the client IP that many
144
+ * entries from the right of `X-Forwarded-For` via
145
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
146
+ * entries on the left cannot impersonate a verified crawler's IP. Must be
147
+ * an integer in [1, 64]; validated at construction.
148
+ */
149
+ trustedHops?: number;
136
150
  /**
137
151
  * Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
138
152
  */
package/dist/bot-guard.js CHANGED
@@ -36,6 +36,7 @@
36
36
  * @since 0.37.0
37
37
  */
38
38
  import { ForbiddenError } from "./errors.js";
39
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
39
40
  const DEFAULT_MESSAGE = "Bot access denied";
40
41
  const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
41
42
  const DEFAULT_CACHE_MAX = 10_000;
@@ -88,15 +89,13 @@ function matchesUserAgent(ua, patterns) {
88
89
  }
89
90
  return false;
90
91
  }
91
- function forwardedIpResolver(ctx) {
92
- const headers = ctx.request.headers;
93
- const forwarded = headers.get("x-forwarded-for");
94
- if (forwarded) {
95
- const first = forwarded.split(",")[0]?.trim();
96
- if (first)
97
- return first;
98
- }
99
- return ctx.request.headers.get("x-real-ip") ?? undefined;
92
+ /**
93
+ * @internal Read the client IP `hops` entries from the right of
94
+ * `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
95
+ * of the header; see {@link resolveForwardedClientIp}.
96
+ */
97
+ function forwardedIpResolver(hops) {
98
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
100
99
  }
101
100
  function noIpResolver(_ctx) {
102
101
  return undefined;
@@ -210,9 +209,10 @@ export function botGuard(opts = {}) {
210
209
  if (mode !== "block" && mode !== "log") {
211
210
  throw new Error('botGuard(): mode must be "block" or "log".');
212
211
  }
213
- const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
214
- if (verifiedBots.length > 0 && !opts.resolveIp && !opts.trustProxyHeaders) {
215
- throw new Error("botGuard(): verifiedBots requires a client-IP source provide resolveIp " +
212
+ const hops = resolveForwardedTrust("botGuard()", opts);
213
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
214
+ if (verifiedBots.length > 0 && !opts.resolveIp && hops === undefined) {
215
+ throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp, trustedHops, " +
216
216
  "or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
217
217
  }
218
218
  const resolver = opts.resolver ?? createDefaultResolver();
package/dist/cli.js CHANGED
@@ -274,7 +274,11 @@ export function parseArgs(argv) {
274
274
  };
275
275
  let command = "inspect";
276
276
  let i = 0;
277
- if (argv[0] === "inspect" || argv[0] === "dev" || argv[0] === "help" || argv[0] === "doctor" || argv[0] === "diff") {
277
+ if (argv[0] === "inspect" ||
278
+ argv[0] === "dev" ||
279
+ argv[0] === "help" ||
280
+ argv[0] === "doctor" ||
281
+ argv[0] === "diff") {
278
282
  command = argv[0];
279
283
  i = 1;
280
284
  }
@@ -593,7 +597,8 @@ async function runDoctor(opts, io) {
593
597
  const o = app.options;
594
598
  const isProd = o.env === "production" ||
595
599
  o.production === true ||
596
- globalThis.process?.env?.NODE_ENV === "production";
600
+ globalThis.process?.env?.NODE_ENV ===
601
+ "production";
597
602
  if (opts.noAuditDefaults !== true) {
598
603
  if (isProd && o.trustProxy === undefined && o.behindProxy === undefined) {
599
604
  findings.push({
@@ -907,10 +912,8 @@ export function buildAiDump(app, opts) {
907
912
  method: def.method,
908
913
  path: def.path,
909
914
  ...(def.operationId ? { operationId: def.operationId } : {}),
910
- ...(def.summary ?? meta?.summary
911
- ? { summary: def.summary ?? meta?.summary }
912
- : {}),
913
- ...(def.description ?? meta?.description
915
+ ...((def.summary ?? meta?.summary) ? { summary: def.summary ?? meta?.summary } : {}),
916
+ ...((def.description ?? meta?.description)
914
917
  ? { description: def.description ?? meta?.description }
915
918
  : {}),
916
919
  tags: dedupeTags(def.tags, meta?.tags),
@@ -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