@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.
- package/README.md +25 -14
- package/dist/adapters/bun.js +1 -2
- package/dist/adapters/node.js +16 -30
- package/dist/app.d.ts +5 -1
- package/dist/app.js +74 -6
- package/dist/auto-ban.d.ts +16 -0
- package/dist/auto-ban.js +20 -12
- package/dist/bot-guard.d.ts +14 -0
- package/dist/bot-guard.js +12 -12
- package/dist/cli.js +9 -6
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.d.ts +15 -0
- package/dist/geo-block.js +14 -19
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -4
- package/dist/ip-reputation.d.ts +14 -0
- package/dist/ip-reputation.js +11 -11
- package/dist/ip-restriction.d.ts +14 -0
- package/dist/ip-restriction.js +7 -18
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- 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/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/safe-redirect.js +3 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/waf.js +40 -8
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- 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
|
-
##
|
|
31
|
+
## Acknowledgements
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 / ≥
|
|
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.
|
|
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 ≥
|
|
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
|
-
-
|
|
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, `
|
|
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`
|
|
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,
|
|
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
|
|
package/dist/adapters/bun.js
CHANGED
|
@@ -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
|
}
|
package/dist/adapters/node.js
CHANGED
|
@@ -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
|
|
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) => {
|
|
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
|
-
|
|
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" &&
|
|
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
|
-
|
|
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. **
|
|
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. **
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
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 {
|
|
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) };
|
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) {
|
|
@@ -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 });
|
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();
|
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" ||
|
|
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 ===
|
|
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
|
-
|
|
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
|