@daloyjs/core 1.0.0 → 1.1.1
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 +4 -3
- package/dist/adapters/node.d.ts +6 -1
- package/dist/adapters/node.js +54 -0
- package/dist/auto-ban.d.ts +21 -6
- package/dist/auto-ban.js +10 -8
- package/dist/bot-guard.d.ts +12 -2
- package/dist/bot-guard.js +7 -5
- package/dist/concurrency-limit.d.ts +10 -0
- package/dist/concurrency-limit.js +15 -6
- package/dist/conn-info.d.ts +54 -9
- package/dist/conn-info.js +142 -14
- package/dist/fetch-guard.js +1 -1
- package/dist/geo-block.d.ts +11 -0
- package/dist/geo-block.js +6 -4
- package/dist/ip-match.d.ts +62 -0
- package/dist/ip-match.js +171 -0
- package/dist/ip-reputation.d.ts +10 -0
- package/dist/ip-reputation.js +7 -5
- package/dist/ip-restriction.d.ts +13 -48
- package/dist/ip-restriction.js +10 -161
- package/dist/jwt.d.ts +15 -1
- package/dist/jwt.js +35 -1
- package/dist/middleware.d.ts +27 -2
- package/dist/middleware.js +26 -7
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -584,7 +584,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
|
|
|
584
584
|
|
|
585
585
|
## Status
|
|
586
586
|
|
|
587
|
-
DaloyJS is at **`1.
|
|
587
|
+
DaloyJS is at **`1.1.1`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
|
|
588
588
|
|
|
589
589
|
The release-candidate train that led here was largely adversarial: `rc.1` through `rc.9` carried remediations from live over-the-wire engagements against realistic multi-tenant apps, and the findings clustered in one place worth naming — **composition**, not individual modules. A `responseCache()` mounted ahead of the network-identity gates silently disabled them; the same order left `rateLimit()` never counting the requests a cache hit or an idempotent replay served; a forwarded-header resolver read the one `X-Forwarded-For` slot an attacker controls; `idempotency()` replayed a stored `Set-Cookie`. Each is fixed, each has a regression test, and several are now refuse-to-boot guards so the unsafe wiring cannot ship quietly. See the [CHANGELOG](CHANGELOG.md) for the full train and [boot guards](https://daloyjs.dev/docs/security/boot-guards) for the orders the framework now refuses.
|
|
590
590
|
|
|
@@ -637,7 +637,8 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
637
637
|
- **Unauthenticated MCP**: an `mcpRoutes()` endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server with `mcpRoutes(path, handler, { public: true })`.
|
|
638
638
|
- `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
|
|
639
639
|
- `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
|
|
640
|
-
- `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`,
|
|
640
|
+
- `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2), and refuse JWS `crit` headers at both sign and verify time (RFC 7515 §4.1.11 — no extensions are implemented, so none may be silently ignored). The verifier also accepts an opt-in `maxLifetimeSeconds` cap that rejects any token whose `exp - (iat ?? now)` exceeds it (`lifetime_exceeded`), closing the signer/verifier lifetime asymmetry.
|
|
641
|
+
- Node adapter header-count hardening: because Node's llhttp parser *silently truncates* headers past `server.maxHeadersCount` instead of rejecting, the adapter answers `431` for any HTTP request *or WebSocket upgrade* whose raw header-field count reaches the cap — a truncated flood is indistinguishable from a handshake sitting exactly at it, so it is refused rather than completed with dropped fields (usable default budget: 99 fields).
|
|
641
642
|
- `secureHeaders()` refuses to construct with `frameOptions: false` AND no CSP `frame-ancestors` directive (no clickjacking defense).
|
|
642
643
|
- `cors()` refuses `methods: ['*']` at construction; default `allowMethods` narrowed to `[GET, HEAD, POST]` so `PUT` / `PATCH` / `DELETE` become explicit opt-ins.
|
|
643
644
|
- `cspReportRoute()` refuses non-`application/json` (415) and refuses `maxBodyBytes > 64 KiB` at construction. The default production logger sink omits the parsed report body unless `logCspReportBodies: true` is set explicitly.
|
|
@@ -670,7 +671,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
670
671
|
- `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
|
|
671
672
|
- `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
|
|
672
673
|
- `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
|
|
673
|
-
- `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator`, `trustedHops`, or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Proxy-header identity is **spoof-resistant**: the client IP is read from the rightmost `X-Forwarded-For` entry (the one your proxy appended) via `resolveForwardedClientIp()`, so rotating spoofed left entries can neither evade strike accumulation nor frame a victim IP for banning; multi-hop chains declare their hop count with `trustedHops` (shared by `rateLimit()`, `loginThrottle()`, `concurrencyLimit()`, `geoBlock()`, `ipRestriction()`, `ipReputation()`, and `botGuard()`). Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
|
|
674
|
+
- `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator`, `trustedHops`, or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Proxy-header identity is **spoof-resistant**: the client IP is read from the rightmost `X-Forwarded-For` entry (the one your proxy appended) via `resolveForwardedClientIp()`, so rotating spoofed left entries can neither evade strike accumulation nor frame a victim IP for banning; multi-hop chains declare their hop count with `trustedHops` (shared by `rateLimit()`, `loginThrottle()`, `concurrencyLimit()`, `geoBlock()`, `ipRestriction()`, `ipReputation()`, and `botGuard()`). For deployments where the origin itself is reachable, `trustedProxies` (a CIDR allowlist of your proxy peer addresses, accepted by every guard in that list) goes further: the immediate TCP peer is verified against the allowlist before any forwarded header is believed, so a direct-to-origin caller's spoofed `X-Forwarded-For` is ignored entirely — closing victim-IP framing and ban/limit evasion at the framework layer, and failing closed on peer-less edge platforms. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
|
|
674
675
|
- `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
|
|
675
676
|
- `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed; **SSRF-hardened by default** — the outbound fetch runs through `fetchGuard()`, so a compromised feed host can't redirect it into cloud-metadata / internal space; override via `fetchImpl`) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
|
|
676
677
|
- `geoBlock()` GeoIP / geo-blocking at `@daloyjs/core/geo-block`: country allow/deny middleware that maps the client IP to an ISO 3166-1 alpha-2 country and rejects (or logs) traffic from countries you don't serve. **No bundled GeoIP database and no runtime dependency** — supply either an operator-owned `lookupCountry(ip)` (a MaxMind / `ip2location` reader, or your own table, reusing the trusted-proxy `X-Forwarded-For` / `X-Real-IP` IP resolution) or a `resolveCountry(ctx)` that reads an edge-injected header (`CF-IPCountry`, `CloudFront-Viewer-Country`, `x-vercel-ip-country`). Deny wins over allow (least privilege); **allow-lists fail closed** on an unknown country while deny-only fails open (overridable via `allowUnknownCountry`). Country codes are validated at construction so typos throw instead of silently never matching. `mode: "log"` monitor mode with an `onBlock` decision hook (`denied_country` / `not_in_allowlist` / `unknown_country`), the resolved country stamped on `ctx.state.geo` for allowed requests, and a `403` problem+json rejection that never echoes the country/IP. Zero runtime dependencies.
|
package/dist/adapters/node.d.ts
CHANGED
|
@@ -37,7 +37,12 @@ export interface NodeServerOptions {
|
|
|
37
37
|
* amplification (the dimension abused by the "HTTP/2 Bomb"). Node's own
|
|
38
38
|
* default is `2000`; this adapter tightens it to `100` to mirror the
|
|
39
39
|
* application-tier cap. Set `0` to disable (use Node's unbounded default).
|
|
40
|
-
*
|
|
40
|
+
*
|
|
41
|
+
* Note: Node's parser (llhttp) *silently truncates* headers past this cap
|
|
42
|
+
* rather than rejecting the request, so the adapter additionally refuses
|
|
43
|
+
* any request *or WebSocket upgrade* whose raw field count reaches the cap
|
|
44
|
+
* with `431` — at the cap, a truncated flood is indistinguishable from a
|
|
45
|
+
* legitimate handshake. Default: 100.
|
|
41
46
|
*
|
|
42
47
|
* @since 0.38.0
|
|
43
48
|
*/
|
package/dist/adapters/node.js
CHANGED
|
@@ -35,6 +35,15 @@ export function serve(app, opts = {}) {
|
|
|
35
35
|
? Math.max(1_000, Math.min(5_000, Math.floor(connectionTimeoutMs / 2)))
|
|
36
36
|
: undefined;
|
|
37
37
|
const handleRequest = (req, res, onBodyPull) => {
|
|
38
|
+
// Node's HTTP parser *silently truncates* headers past
|
|
39
|
+
// `server.maxHeadersCount` instead of rejecting the request (llhttp
|
|
40
|
+
// semantics — verified live against Node 24). See
|
|
41
|
+
// {@link isAtOrOverParserHeaderCap}. Cost: two property reads + compare.
|
|
42
|
+
const headerCap = server.maxHeadersCount ?? 0;
|
|
43
|
+
if (isAtOrOverParserHeaderCap(req, headerCap)) {
|
|
44
|
+
writeHeadersTooLarge(res, headerCap);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
38
47
|
// GET/HEAD: no body work, dispatch directly. Keep this first so the GET
|
|
39
48
|
// hot path doesn't pay for any of the buffering bookkeeping below.
|
|
40
49
|
const method = req.method;
|
|
@@ -132,6 +141,20 @@ export function serve(app, opts = {}) {
|
|
|
132
141
|
server.on("upgrade", (req, socket, head) => {
|
|
133
142
|
wsSockets.add(socket);
|
|
134
143
|
socket.on("close", () => wsSockets.delete(socket));
|
|
144
|
+
// Same llhttp truncation blind spot as handleRequest: upgrades never
|
|
145
|
+
// pass through the request listener or App.dispatch, so the portable
|
|
146
|
+
// maxHeaderCount guard never runs. Refuse at the parser cap before
|
|
147
|
+
// any Headers construction / 101 handshake.
|
|
148
|
+
const headerCap = server.maxHeadersCount ?? 0;
|
|
149
|
+
if (isAtOrOverParserHeaderCap(req, headerCap)) {
|
|
150
|
+
try {
|
|
151
|
+
writeUpgradeError(socket, 431, "Request Header Fields Too Large");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* socket already closed */
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
135
158
|
// Safety net: a rejection here would otherwise be unhandled and, under
|
|
136
159
|
// the production crash-on-unhandledRejection posture, kill the process
|
|
137
160
|
// from a single malformed upgrade request.
|
|
@@ -327,6 +350,37 @@ function attachClientCertificate(req, request) {
|
|
|
327
350
|
* defensively for runtimes/proxies that surface it as a normal request.)
|
|
328
351
|
*/
|
|
329
352
|
const FETCH_FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
|
|
353
|
+
/**
|
|
354
|
+
* True when Node's parser has retained at least `headerCap` raw header fields.
|
|
355
|
+
* At the cap a truncated flood is indistinguishable from a legitimate request
|
|
356
|
+
* sitting exactly on the limit (llhttp drops excess fields instead of 431).
|
|
357
|
+
* A non-positive `headerCap` disables the check (`maxHeaderCount: 0`).
|
|
358
|
+
*/
|
|
359
|
+
function isAtOrOverParserHeaderCap(req, headerCap) {
|
|
360
|
+
return headerCap > 0 && req.rawHeaders.length / 2 >= headerCap;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Refuse a request whose raw header-field count reached the parser cap —
|
|
364
|
+
* indistinguishable from a flood that llhttp silently truncated. Mirrors
|
|
365
|
+
* {@link writeMethodRefused}'s RFC 9457 problem+json shape; `Connection: close`
|
|
366
|
+
* avoids reusing a socket whose remaining header fields were discarded.
|
|
367
|
+
*
|
|
368
|
+
* @param res - The Node {@link ServerResponse} to write the refusal to.
|
|
369
|
+
* @param cap - The effective parser header-count cap, echoed in the `detail`.
|
|
370
|
+
*/
|
|
371
|
+
function writeHeadersTooLarge(res, cap) {
|
|
372
|
+
if (res.headersSent)
|
|
373
|
+
return;
|
|
374
|
+
res.statusCode = 431;
|
|
375
|
+
res.setHeader("content-type", "application/problem+json");
|
|
376
|
+
res.setHeader("connection", "close");
|
|
377
|
+
res.end(JSON.stringify({
|
|
378
|
+
type: "https://daloyjs.dev/errors/request-header-fields-too-large",
|
|
379
|
+
title: "Request Header Fields Too Large",
|
|
380
|
+
status: 431,
|
|
381
|
+
detail: `Request reached the parser header-count cap (${cap}); excess fields would have been silently dropped.`,
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
330
384
|
/**
|
|
331
385
|
* Refuse a Fetch-forbidden HTTP method with a spec-correct `501 Not
|
|
332
386
|
* Implemented`. `501` is more accurate than `405` here because the method is
|
package/dist/auto-ban.d.ts
CHANGED
|
@@ -92,9 +92,10 @@ export interface AutoBanStrikeEvent {
|
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
94
|
* Configuration for {@link autoBan}. Every field is optional except that the
|
|
95
|
-
* middleware must be able to identify clients: supply a {@link keyGenerator}
|
|
96
|
-
*
|
|
97
|
-
*
|
|
95
|
+
* middleware must be able to identify clients: supply a {@link keyGenerator},
|
|
96
|
+
* {@link trustedHops}, {@link trustedProxies}, or set {@link trustProxyHeaders}
|
|
97
|
+
* (otherwise construction throws, to avoid accidentally banning every client
|
|
98
|
+
* through a shared `"global"` bucket).
|
|
98
99
|
*
|
|
99
100
|
* @since 0.37.0
|
|
100
101
|
*/
|
|
@@ -166,6 +167,19 @@ export interface AutoBanOptions {
|
|
|
166
167
|
* [1, 64]; validated at construction.
|
|
167
168
|
*/
|
|
168
169
|
trustedHops?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist (e.g.
|
|
172
|
+
* `["10.0.0.0/8", "203.0.113.10"]`) for the immediate peer's address.
|
|
173
|
+
* Forwarded headers are honoured only when the TCP socket actually talking
|
|
174
|
+
* to the adapter matches the list — the one property a remote client
|
|
175
|
+
* cannot spoof. A direct-to-origin attacker then falls back to the
|
|
176
|
+
* unspoofable peer identity, so a spoofed `X-Forwarded-For` can neither
|
|
177
|
+
* frame a victim IP for banning nor rotate away strike accumulation.
|
|
178
|
+
* Implies proxy-header trust at one hop unless {@link trustedHops} says
|
|
179
|
+
* otherwise; validated and compiled at construction. On peer-less edge
|
|
180
|
+
* platforms verification fails closed (forwarded identity ignored).
|
|
181
|
+
*/
|
|
182
|
+
trustedProxies?: readonly string[];
|
|
169
183
|
/**
|
|
170
184
|
* What to do when the default key generator cannot resolve a forwarded
|
|
171
185
|
* identity — the request carried no `X-Forwarded-For`, or a chain shorter than
|
|
@@ -238,7 +252,8 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
|
|
|
238
252
|
* offenders; bans grow exponentially for persistent abuse and decay once the
|
|
239
253
|
* client goes quiet.
|
|
240
254
|
*
|
|
241
|
-
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator}
|
|
255
|
+
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator},
|
|
256
|
+
* {@link AutoBanOptions.trustedHops}, {@link AutoBanOptions.trustedProxies}, or
|
|
242
257
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
243
258
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
244
259
|
* ban the whole world at once. When the default generator cannot resolve a
|
|
@@ -258,8 +273,8 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
|
|
|
258
273
|
*
|
|
259
274
|
* @param opts - Auto-ban configuration.
|
|
260
275
|
* @returns A {@link Hooks} bundle ready for `app.use(...)`.
|
|
261
|
-
* @throws Error when
|
|
262
|
-
* or when a numeric option is out of range.
|
|
276
|
+
* @throws Error when none of `keyGenerator`, `trustedHops`, `trustedProxies`, or
|
|
277
|
+
* `trustProxyHeaders` is provided, or when a numeric option is out of range.
|
|
263
278
|
* @since 0.37.0
|
|
264
279
|
*/
|
|
265
280
|
export declare function autoBan(opts?: AutoBanOptions): Hooks;
|
package/dist/auto-ban.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @since 0.37.0
|
|
21
21
|
*/
|
|
22
22
|
import { ForbiddenError, TooManyRequestsError } from "./errors.js";
|
|
23
|
-
import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
23
|
+
import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
24
24
|
const DEFAULT_WINDOW_MS = 10 * 60_000;
|
|
25
25
|
const DEFAULT_MAX_STRIKES = 5;
|
|
26
26
|
const DEFAULT_BAN_MS = 15 * 60_000;
|
|
@@ -106,9 +106,9 @@ function assertPositiveInteger(name, value) {
|
|
|
106
106
|
* behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
|
|
107
107
|
* the right call.
|
|
108
108
|
*/
|
|
109
|
-
function forwardedKey(hops, peerFallback) {
|
|
109
|
+
function forwardedKey(hops, peerFallback, trustedPeers) {
|
|
110
110
|
return (ctx) => {
|
|
111
|
-
const forwarded = resolveForwardedClientIp(ctx.request, hops);
|
|
111
|
+
const forwarded = resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
112
112
|
if (forwarded !== undefined)
|
|
113
113
|
return forwarded;
|
|
114
114
|
if (!peerFallback)
|
|
@@ -123,7 +123,8 @@ function forwardedKey(hops, peerFallback) {
|
|
|
123
123
|
* offenders; bans grow exponentially for persistent abuse and decay once the
|
|
124
124
|
* client goes quiet.
|
|
125
125
|
*
|
|
126
|
-
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator}
|
|
126
|
+
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator},
|
|
127
|
+
* {@link AutoBanOptions.trustedHops}, {@link AutoBanOptions.trustedProxies}, or
|
|
127
128
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
128
129
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
129
130
|
* ban the whole world at once. When the default generator cannot resolve a
|
|
@@ -143,8 +144,8 @@ function forwardedKey(hops, peerFallback) {
|
|
|
143
144
|
*
|
|
144
145
|
* @param opts - Auto-ban configuration.
|
|
145
146
|
* @returns A {@link Hooks} bundle ready for `app.use(...)`.
|
|
146
|
-
* @throws Error when
|
|
147
|
-
* or when a numeric option is out of range.
|
|
147
|
+
* @throws Error when none of `keyGenerator`, `trustedHops`, `trustedProxies`, or
|
|
148
|
+
* `trustProxyHeaders` is provided, or when a numeric option is out of range.
|
|
148
149
|
* @since 0.37.0
|
|
149
150
|
*/
|
|
150
151
|
export function autoBan(opts = {}) {
|
|
@@ -177,6 +178,7 @@ export function autoBan(opts = {}) {
|
|
|
177
178
|
}
|
|
178
179
|
const watch = new Set(watchStatuses);
|
|
179
180
|
const hops = resolveForwardedTrust("autoBan()", opts);
|
|
181
|
+
const proxyMatchers = resolveTrustedProxyMatchers("autoBan()", opts);
|
|
180
182
|
const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
|
|
181
183
|
if (onUnresolved !== "peer" && onUnresolved !== "skip") {
|
|
182
184
|
throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
|
|
@@ -186,10 +188,10 @@ export function autoBan(opts = {}) {
|
|
|
186
188
|
keyOf = opts.keyGenerator;
|
|
187
189
|
}
|
|
188
190
|
else if (hops !== undefined) {
|
|
189
|
-
keyOf = forwardedKey(hops, onUnresolved === "peer");
|
|
191
|
+
keyOf = forwardedKey(hops, onUnresolved === "peer", proxyMatchers);
|
|
190
192
|
}
|
|
191
193
|
else {
|
|
192
|
-
throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
|
|
194
|
+
throw new Error("autoBan(): provide keyGenerator, trustedHops, trustedProxies, or set trustProxyHeaders so clients can be identified; " +
|
|
193
195
|
"otherwise every caller shares one bucket and a single offender would ban everyone.");
|
|
194
196
|
}
|
|
195
197
|
const groupId = opts.groupId ?? DEFAULT_GROUP_ID;
|
package/dist/bot-guard.d.ts
CHANGED
|
@@ -117,8 +117,8 @@ export interface BotGuardOptions {
|
|
|
117
117
|
allowUserAgents?: readonly (string | RegExp)[];
|
|
118
118
|
/**
|
|
119
119
|
* Declared-crawler verification rules. When provided, an IP source is
|
|
120
|
-
* required (`resolveIp`
|
|
121
|
-
* throws.
|
|
120
|
+
* required (`resolveIp`, `trustedHops`, `trustedProxies`, or
|
|
121
|
+
* `trustProxyHeaders`), otherwise construction throws.
|
|
122
122
|
*/
|
|
123
123
|
verifiedBots?: readonly VerifiedBotRule[];
|
|
124
124
|
/**
|
|
@@ -147,6 +147,16 @@ export interface BotGuardOptions {
|
|
|
147
147
|
* an integer in [1, 64]; validated at construction.
|
|
148
148
|
*/
|
|
149
149
|
trustedHops?: number;
|
|
150
|
+
/**
|
|
151
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
152
|
+
* peer's address. Forwarded headers are honoured only when the TCP socket
|
|
153
|
+
* actually talking to the adapter matches the list, so a direct-to-origin
|
|
154
|
+
* attacker cannot impersonate a verified crawler's IP. Implies
|
|
155
|
+
* proxy-header trust at one hop unless {@link trustedHops} says otherwise;
|
|
156
|
+
* validated and compiled at construction. On peer-less edge platforms
|
|
157
|
+
* verification fails closed.
|
|
158
|
+
*/
|
|
159
|
+
trustedProxies?: readonly string[];
|
|
150
160
|
/**
|
|
151
161
|
* Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
|
|
152
162
|
*/
|
package/dist/bot-guard.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* @since 0.37.0
|
|
37
37
|
*/
|
|
38
38
|
import { ForbiddenError } from "./errors.js";
|
|
39
|
-
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
39
|
+
import { resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
40
40
|
const DEFAULT_MESSAGE = "Bot access denied";
|
|
41
41
|
const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
|
|
42
42
|
const DEFAULT_CACHE_MAX = 10_000;
|
|
@@ -94,8 +94,8 @@ function matchesUserAgent(ua, patterns) {
|
|
|
94
94
|
* `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
|
|
95
95
|
* of the header; see {@link resolveForwardedClientIp}.
|
|
96
96
|
*/
|
|
97
|
-
function forwardedIpResolver(hops) {
|
|
98
|
-
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
97
|
+
function forwardedIpResolver(hops, trustedPeers) {
|
|
98
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
99
99
|
}
|
|
100
100
|
function noIpResolver(_ctx) {
|
|
101
101
|
return undefined;
|
|
@@ -210,10 +210,12 @@ export function botGuard(opts = {}) {
|
|
|
210
210
|
throw new Error('botGuard(): mode must be "block" or "log".');
|
|
211
211
|
}
|
|
212
212
|
const hops = resolveForwardedTrust("botGuard()", opts);
|
|
213
|
-
const
|
|
213
|
+
const proxyMatchers = resolveTrustedProxyMatchers("botGuard()", opts);
|
|
214
|
+
const resolveIp = opts.resolveIp ??
|
|
215
|
+
(hops !== undefined ? forwardedIpResolver(hops, proxyMatchers) : noIpResolver);
|
|
214
216
|
if (verifiedBots.length > 0 && !opts.resolveIp && hops === undefined) {
|
|
215
217
|
throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp, trustedHops, " +
|
|
216
|
-
"or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
|
|
218
|
+
"trustedProxies, or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
|
|
217
219
|
}
|
|
218
220
|
const resolver = opts.resolver ?? createDefaultResolver();
|
|
219
221
|
// Per-IP verification cache (keyed by `ip\u0000botName`) so a crawler's DNS
|
|
@@ -113,6 +113,16 @@ export interface ConcurrencyLimitOptions {
|
|
|
113
113
|
* in [1, 64]; validated at construction.
|
|
114
114
|
*/
|
|
115
115
|
trustedHops?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
118
|
+
* peer's address when `scope: "client"`. Forwarded headers feed the bucket
|
|
119
|
+
* key only when the TCP socket actually talking to the adapter matches the
|
|
120
|
+
* list, so a direct-to-origin attacker cannot hop buckets with spoofed XFF
|
|
121
|
+
* to hoard slots. Implies proxy-header trust at one hop unless
|
|
122
|
+
* {@link trustedHops} says otherwise; validated and compiled at
|
|
123
|
+
* construction. On peer-less edge platforms verification fails closed.
|
|
124
|
+
*/
|
|
125
|
+
trustedProxies?: readonly string[];
|
|
116
126
|
/**
|
|
117
127
|
* Custom client-identity resolver for `scope: "client"`. Overrides
|
|
118
128
|
* {@link trustProxyHeaders}. Returning `undefined` skips limiting for the
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* @since 0.37.0
|
|
47
47
|
*/
|
|
48
48
|
import { HttpError } from "./errors.js";
|
|
49
|
-
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
49
|
+
import { getConnInfo, resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
50
50
|
const DEFAULT_MESSAGE = "Concurrency limit exceeded";
|
|
51
51
|
/** Monotonic id so multiple mounted limiters use distinct per-request state slots. */
|
|
52
52
|
let instanceCounter = 0;
|
|
@@ -64,9 +64,17 @@ function assertNonNegativeInteger(name, value) {
|
|
|
64
64
|
* @internal Default identity resolver: the client IP `hops` entries from the
|
|
65
65
|
* right of `X-Forwarded-For` (falling back to `X-Real-IP`) — the
|
|
66
66
|
* spoof-resistant side of the header; see {@link resolveForwardedClientIp}.
|
|
67
|
+
* When no trustworthy forwarded identity exists, falls back to the unspoofable
|
|
68
|
+
* TCP peer so a missing/spoofed XFF cannot skip the limit entirely (undefined
|
|
69
|
+
* would fail open). Peer-less platforms still return `undefined` (skip).
|
|
67
70
|
*/
|
|
68
|
-
function forwardedKey(hops) {
|
|
69
|
-
return (ctx) =>
|
|
71
|
+
function forwardedKey(hops, trustedPeers) {
|
|
72
|
+
return (ctx) => {
|
|
73
|
+
const forwarded = resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
74
|
+
if (forwarded !== undefined)
|
|
75
|
+
return forwarded;
|
|
76
|
+
return getConnInfo(ctx.request)?.remoteAddress;
|
|
77
|
+
};
|
|
70
78
|
}
|
|
71
79
|
/** Extract just the pathname from a request URL without a full `URL` parse where possible. */
|
|
72
80
|
function pathnameOf(url) {
|
|
@@ -107,16 +115,17 @@ function buildScopeResolver(opts) {
|
|
|
107
115
|
}
|
|
108
116
|
// scope === "client"
|
|
109
117
|
const hops = resolveForwardedTrust("concurrencyLimit()", opts);
|
|
118
|
+
const proxyMatchers = resolveTrustedProxyMatchers("concurrencyLimit()", opts);
|
|
110
119
|
let resolve;
|
|
111
120
|
if (opts.keyGenerator) {
|
|
112
121
|
resolve = opts.keyGenerator;
|
|
113
122
|
}
|
|
114
123
|
else if (hops !== undefined) {
|
|
115
|
-
resolve = forwardedKey(hops);
|
|
124
|
+
resolve = forwardedKey(hops, proxyMatchers);
|
|
116
125
|
}
|
|
117
126
|
else {
|
|
118
|
-
throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, or
|
|
119
|
-
"clients can be identified; otherwise every caller shares one bucket.");
|
|
127
|
+
throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, trustedProxies, or ' +
|
|
128
|
+
"trustProxyHeaders so clients can be identified; otherwise every caller shares one bucket.");
|
|
120
129
|
}
|
|
121
130
|
return (ctx) => {
|
|
122
131
|
const id = resolve(ctx);
|
package/dist/conn-info.d.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* @since 0.24.0
|
|
20
20
|
*/
|
|
21
21
|
import type { BaseContext } from "./types.js";
|
|
22
|
+
import { type IpMatcher } from "./ip-match.js";
|
|
22
23
|
/**
|
|
23
24
|
* Declarative reverse-proxy posture. Replaces the
|
|
24
25
|
* foot-gunny `trustProxy: boolean` with a structured value that
|
|
@@ -127,23 +128,57 @@ export declare function pickForwardedForByHops(header: string | null, hops: numb
|
|
|
127
128
|
* `behindProxy.hops` range: the floor of one exists because a middleware that
|
|
128
129
|
* trusts zero proxy hops has no business reading forwarding headers at all.
|
|
129
130
|
*
|
|
131
|
+
* `trustedProxies` (a CIDR allowlist of proxy peer addresses) also enables
|
|
132
|
+
* forwarded-header trust, defaulting to one hop when `trustedHops` is not
|
|
133
|
+
* set: declaring WHO your proxies are is meaningless unless their headers
|
|
134
|
+
* are then read. Pair it with {@link resolveTrustedProxyMatchers} and pass
|
|
135
|
+
* the compiled matchers to {@link resolveForwardedClientIp} so the forwarded
|
|
136
|
+
* identity is honoured only when the immediate TCP peer is a verified proxy.
|
|
137
|
+
*
|
|
130
138
|
* @param name - Middleware function name used in error messages.
|
|
131
|
-
* @param opts - The middleware's options object; only the
|
|
139
|
+
* @param opts - The middleware's options object; only the trust fields are
|
|
132
140
|
* read, so any middleware option type is structurally acceptable.
|
|
133
141
|
* @returns The number of trusted proxy hops when forwarded-header trust is
|
|
134
|
-
* enabled — `trustedHops` verbatim,
|
|
135
|
-
* `trustProxyHeaders: true
|
|
136
|
-
*
|
|
142
|
+
* enabled — `trustedHops` verbatim, `1` for a bare
|
|
143
|
+
* `trustProxyHeaders: true`, or `1` when `trustedProxies` is declared
|
|
144
|
+
* without an explicit hop count — or `undefined` when trust is off and the
|
|
145
|
+
* caller must not read forwarding headers at all.
|
|
137
146
|
* @throws Error when `trustedHops` is not an integer in [1, 64], or when
|
|
138
|
-
* `trustProxyHeaders: false` is combined with a `trustedHops` value
|
|
139
|
-
*
|
|
140
|
-
* of trust — meaning an explicit
|
|
147
|
+
* `trustProxyHeaders: false` is combined with a `trustedHops` value or a
|
|
148
|
+
* `trustedProxies` list. Those pairings are contradictions, and the first
|
|
149
|
+
* previously resolved silently in favour of trust — meaning an explicit
|
|
150
|
+
* opt-out was ignored.
|
|
141
151
|
* @internal
|
|
142
152
|
*/
|
|
143
153
|
export declare function resolveForwardedTrust(name: string, opts: {
|
|
144
154
|
trustedHops?: number;
|
|
145
155
|
trustProxyHeaders?: boolean;
|
|
156
|
+
trustedProxies?: readonly string[];
|
|
146
157
|
}): number | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Validate and compile a middleware's `trustedProxies` CIDR allowlist into
|
|
160
|
+
* matchers usable with {@link resolveForwardedClientIp}. Called once at
|
|
161
|
+
* construction so the per-request cost of peer verification is a handful of
|
|
162
|
+
* byte comparisons, never string parsing.
|
|
163
|
+
*
|
|
164
|
+
* The allowlist answers the question `trustedHops` alone cannot: not "how
|
|
165
|
+
* many proxies are in front of me" but "is the socket actually talking to me
|
|
166
|
+
* one of MY proxies". Without it, any client that can reach the origin
|
|
167
|
+
* directly can claim any `X-Forwarded-For` identity — the victim-IP framing
|
|
168
|
+
* and ban-evasion classes documented on {@link resolveForwardedClientIp}.
|
|
169
|
+
*
|
|
170
|
+
* @param name - Middleware function name used in error messages.
|
|
171
|
+
* @param opts - The middleware's options object; only `trustedProxies` is read.
|
|
172
|
+
* @returns The compiled matchers, or `undefined` when `trustedProxies` is not
|
|
173
|
+
* declared (no peer verification — the pre-existing posture).
|
|
174
|
+
* @throws Error when the list is empty (a silent "trust nobody" foot-gun) or
|
|
175
|
+
* any entry is not a valid IP/CIDR. Both are refuse-at-construction
|
|
176
|
+
* misconfigurations, never request-time surprises.
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
export declare function resolveTrustedProxyMatchers(name: string, opts: {
|
|
180
|
+
trustedProxies?: readonly string[];
|
|
181
|
+
}): IpMatcher[] | undefined;
|
|
147
182
|
/**
|
|
148
183
|
* Resolve the client IP from the proxy-set forwarding headers, walking a
|
|
149
184
|
* declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
|
|
@@ -165,17 +200,27 @@ export declare function resolveForwardedTrust(name: string, opts: {
|
|
|
165
200
|
* Security note: this resolver is only meaningful when every request reaches
|
|
166
201
|
* the app through a proxy chain you control that appends (or overwrites)
|
|
167
202
|
* these headers. With no proxy in front, any forwarded-header trust is
|
|
168
|
-
* attacker-controlled by definition.
|
|
203
|
+
* attacker-controlled by definition. `trustedPeers` closes that gap at the
|
|
204
|
+
* framework layer: when supplied, the forwarded identity is honoured only
|
|
205
|
+
* when the immediate TCP peer — the socket actually talking to the adapter,
|
|
206
|
+
* which a remote client cannot spoof — is inside the declared proxy
|
|
207
|
+
* allowlist. A direct-to-origin attacker then gets `undefined` (no
|
|
208
|
+
* identity), so spoofed headers can neither frame a victim nor rotate away
|
|
209
|
+
* strikes. When conn metadata is absent (peer-less edge platforms),
|
|
210
|
+
* verification fails closed.
|
|
169
211
|
*
|
|
170
212
|
* @param request - Incoming request whose forwarding headers are read.
|
|
171
213
|
* @param hops - Number of trusted proxy hops; `1` (default) reads the
|
|
172
214
|
* rightmost entry — the one your immediate proxy appended.
|
|
215
|
+
* @param trustedPeers - Optional compiled allowlist from
|
|
216
|
+
* {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
|
|
217
|
+
* are honoured only if the immediate peer matches; otherwise `undefined`.
|
|
173
218
|
* @returns The resolved client IP, or `undefined` when no forwarded identity
|
|
174
219
|
* is available. Callers decide their own posture for `undefined`
|
|
175
220
|
* (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
|
|
176
221
|
* @since 1.0.0-rc.7
|
|
177
222
|
*/
|
|
178
|
-
export declare function resolveForwardedClientIp(request: Request, hops?: number): string | undefined;
|
|
223
|
+
export declare function resolveForwardedClientIp(request: Request, hops?: number, trustedPeers?: readonly IpMatcher[]): string | undefined;
|
|
179
224
|
/**
|
|
180
225
|
* Resolve the client IP for this request using the configured
|
|
181
226
|
* {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
|