@daloyjs/core 1.0.0-rc.7 → 1.0.0-rc.9
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/dist/adapters/node.js +76 -14
- package/dist/app.d.ts +25 -0
- package/dist/app.js +84 -0
- package/dist/auto-ban.d.ts +49 -5
- package/dist/auto-ban.js +99 -24
- package/dist/bot-guard.d.ts +2 -2
- package/dist/bot-guard.js +6 -1
- package/dist/geo-block.d.ts +3 -3
- package/dist/geo-block.js +7 -1
- package/dist/idempotency.d.ts +69 -2
- package/dist/idempotency.js +151 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ip-reputation.d.ts +2 -2
- package/dist/ip-reputation.js +6 -1
- package/dist/ip-restriction.d.ts +3 -3
- package/dist/ip-restriction.js +7 -2
- package/dist/mcp.d.ts +4 -12
- package/dist/safe-redirect.js +19 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/types.d.ts +31 -0
- package/dist/waf.js +38 -2
- package/dist/websocket.d.ts +48 -5
- package/dist/websocket.js +57 -5
- package/package.json +6 -4
package/dist/adapters/node.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
|
-
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, DALOY_REQUEST_BODY_SOLICIT, } from "../app.js";
|
|
8
8
|
import { BadRequestError } from "../errors.js";
|
|
9
9
|
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
10
10
|
import { setConnInfo } from "../conn-info.js";
|
|
@@ -34,15 +34,12 @@ export function serve(app, opts = {}) {
|
|
|
34
34
|
const connectionsCheckingInterval = connectionTimeoutMs > 0
|
|
35
35
|
? Math.max(1_000, Math.min(5_000, Math.floor(connectionTimeoutMs / 2)))
|
|
36
36
|
: undefined;
|
|
37
|
-
const
|
|
38
|
-
maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
|
|
39
|
-
...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
|
|
40
|
-
}, (req, res) => {
|
|
37
|
+
const handleRequest = (req, res, onBodyPull) => {
|
|
41
38
|
// GET/HEAD: no body work, dispatch directly. Keep this first so the GET
|
|
42
39
|
// hot path doesn't pay for any of the buffering bookkeeping below.
|
|
43
40
|
const method = req.method;
|
|
44
41
|
if (method === "GET" || method === "HEAD" || method === undefined) {
|
|
45
|
-
dispatchToApp(app, req, res, trustProxy, undefined);
|
|
42
|
+
dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
|
|
46
43
|
return;
|
|
47
44
|
}
|
|
48
45
|
// Refuse Fetch-forbidden methods (CONNECT/TRACE/TRACK) before building a
|
|
@@ -61,10 +58,60 @@ export function serve(app, opts = {}) {
|
|
|
61
58
|
const cl = req.headers["content-length"];
|
|
62
59
|
const n = cl ? Number(cl) : NaN;
|
|
63
60
|
if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
|
|
64
|
-
bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
|
|
61
|
+
bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes, onBodyPull), (e) => writeAdapterError(res, e));
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
|
-
dispatchToApp(app, req, res, trustProxy, undefined);
|
|
64
|
+
dispatchToApp(app, req, res, trustProxy, undefined, onBodyPull);
|
|
65
|
+
};
|
|
66
|
+
const server = createServer({
|
|
67
|
+
maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024,
|
|
68
|
+
...(connectionsCheckingInterval !== undefined ? { connectionsCheckingInterval } : {}),
|
|
69
|
+
}, handleRequest);
|
|
70
|
+
// `Expect: 100-continue`: hold the interim response until the framework
|
|
71
|
+
// actually reaches for the body.
|
|
72
|
+
//
|
|
73
|
+
// Node's default is to answer `100 Continue` for anyone who asks, which
|
|
74
|
+
// solicits a body the framework may be about to refuse outright. A route with
|
|
75
|
+
// a request-body schema and a declared `Content-Length` over `bodyLimitBytes`
|
|
76
|
+
// is rejected by `readBodyLimited` *before* it reads a byte, so the interim
|
|
77
|
+
// `100` invited megabytes that could only ever be discarded — measured as
|
|
78
|
+
// `100` then `413` on the wire.
|
|
79
|
+
//
|
|
80
|
+
// Deferring to the first actual read makes the framework's own read decision
|
|
81
|
+
// the predicate, and that is what keeps this honest. An earlier attempt
|
|
82
|
+
// refused at header time against `bodyLimitBytes` directly, but that limit is
|
|
83
|
+
// only enforced where a body is *parsed*, so a route that declares no body
|
|
84
|
+
// schema never applies it: the same request answered `413` with `Expect` and
|
|
85
|
+
// `200` without it. `Expect` is a hint about when to send the body (RFC 9110
|
|
86
|
+
// §10.1.1), so it must never change the outcome — only when the client
|
|
87
|
+
// learns it. Keying off the read keeps the two paths in agreement by
|
|
88
|
+
// construction rather than by test coverage.
|
|
89
|
+
server.on("checkContinue", (req, res) => {
|
|
90
|
+
const cl = req.headers["content-length"];
|
|
91
|
+
const n = cl ? Number(cl) : NaN;
|
|
92
|
+
if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
|
|
93
|
+
// Small declared body: `handleRequest` buffers it eagerly, before dispatch,
|
|
94
|
+
// so there is no later read to key off. It is also under the buffer cap,
|
|
95
|
+
// so soliciting it immediately costs nothing worth deferring.
|
|
96
|
+
res.writeContinue();
|
|
97
|
+
handleRequest(req, res);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Streaming path (no `Content-Length`, or one above the buffer cap): answer
|
|
101
|
+
// the interim `100` the first time the framework pulls the body stream.
|
|
102
|
+
//
|
|
103
|
+
// The socket's own `resume` event is deliberately NOT the trigger. Node also
|
|
104
|
+
// resumes the stream on a microtask when it drains a body nobody read, which
|
|
105
|
+
// races the response: measured, that fired the `100` even for a request whose
|
|
106
|
+
// body was never wanted, and beat the `413` to the wire. The consumer's first
|
|
107
|
+
// `pull` is the only signal that means "the framework wants these bytes".
|
|
108
|
+
let sent = false;
|
|
109
|
+
handleRequest(req, res, () => {
|
|
110
|
+
if (sent || res.headersSent || res.writableEnded)
|
|
111
|
+
return;
|
|
112
|
+
sent = true;
|
|
113
|
+
res.writeContinue();
|
|
114
|
+
});
|
|
68
115
|
});
|
|
69
116
|
server.requestTimeout = connectionTimeoutMs;
|
|
70
117
|
server.headersTimeout = connectionTimeoutMs;
|
|
@@ -148,10 +195,10 @@ export function serve(app, opts = {}) {
|
|
|
148
195
|
* on body size remains `App.bodyLimitBytes`.
|
|
149
196
|
*/
|
|
150
197
|
const DEFAULT_BUFFERED_BODY_MAX_BYTES = 256 * 1024;
|
|
151
|
-
function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
198
|
+
function dispatchToApp(app, req, res, trustProxy, bufferedBody, onBodyPull) {
|
|
152
199
|
let request;
|
|
153
200
|
try {
|
|
154
|
-
request = toWebRequest(req, trustProxy, bufferedBody);
|
|
201
|
+
request = toWebRequest(req, trustProxy, bufferedBody, onBodyPull);
|
|
155
202
|
}
|
|
156
203
|
catch (e) {
|
|
157
204
|
writeAdapterError(res, e);
|
|
@@ -537,7 +584,11 @@ Object.setPrototypeOf(LightRequest.prototype, Request.prototype);
|
|
|
537
584
|
// construction for requests dispatched through this shim. Set once on the
|
|
538
585
|
// prototype: zero per-request cost.
|
|
539
586
|
LightRequest.prototype[DALOY_LIGHT_RESPONSE_OK] = true;
|
|
540
|
-
function toWebRequest(req, trustProxy, bufferedBody) {
|
|
587
|
+
function toWebRequest(req, trustProxy, bufferedBody, onBodyPull) {
|
|
588
|
+
// `onBodyPull` is attached to the finished Request below rather than wrapping
|
|
589
|
+
// the body stream: undici pulls a streaming body during `new Request(...)`,
|
|
590
|
+
// so a stream-level hook fired at construction — before the framework had
|
|
591
|
+
// decided anything — and re-solicited bodies it went on to refuse.
|
|
541
592
|
const reqHeaders = req.headers;
|
|
542
593
|
const forwardedHost = trustProxy ? firstHeader(reqHeaders["x-forwarded-host"]) : undefined;
|
|
543
594
|
const host = forwardedHost ?? reqHeaders.host ?? "localhost";
|
|
@@ -588,12 +639,16 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
588
639
|
req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
|
|
589
640
|
return req2;
|
|
590
641
|
}
|
|
591
|
-
|
|
642
|
+
const streamed = new Request(url, {
|
|
592
643
|
method,
|
|
593
644
|
headers,
|
|
594
645
|
body: Readable.toWeb(req),
|
|
595
646
|
duplex: "half",
|
|
596
647
|
});
|
|
648
|
+
if (onBodyPull !== undefined) {
|
|
649
|
+
streamed[DALOY_REQUEST_BODY_SOLICIT] = onBodyPull;
|
|
650
|
+
}
|
|
651
|
+
return streamed;
|
|
597
652
|
}
|
|
598
653
|
function firstHeader(v) {
|
|
599
654
|
if (v === undefined)
|
|
@@ -874,9 +929,16 @@ class NodeWebSocketConnection {
|
|
|
874
929
|
},
|
|
875
930
|
onClose: (code, reason) => {
|
|
876
931
|
if (this.readyState === WS_READY_STATE.OPEN) {
|
|
877
|
-
// Echo close per RFC 6455 §5.5.1.
|
|
932
|
+
// Echo close per RFC 6455 §5.5.1 ("SHOULD use the same status code").
|
|
933
|
+
// A peer that closed with an *empty* payload surfaces as the 1005
|
|
934
|
+
// sentinel, which §7.4.1 forbids on the wire — echoing it produced a
|
|
935
|
+
// CLOSE(1005) that a conforming peer (and this framework's own
|
|
936
|
+
// decoder) must reject with 1002. An empty close is answered with an
|
|
937
|
+
// empty close.
|
|
878
938
|
this.readyState = WS_READY_STATE.CLOSING;
|
|
879
|
-
this._writeFrame(WS_OPCODE.CLOSE,
|
|
939
|
+
this._writeFrame(WS_OPCODE.CLOSE, code === WS_CLOSE_CODE.NO_STATUS_RECEIVED
|
|
940
|
+
? new Uint8Array(0)
|
|
941
|
+
: encodeClosePayload(code, reason));
|
|
880
942
|
}
|
|
881
943
|
this.readyState = WS_READY_STATE.CLOSED;
|
|
882
944
|
this._fireClose(code, reason);
|
package/dist/app.d.ts
CHANGED
|
@@ -793,6 +793,31 @@ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
|
793
793
|
* adapters can opt in; not part of the userland API surface.
|
|
794
794
|
*/
|
|
795
795
|
export declare const DALOY_REQUEST_ABORT: unique symbol;
|
|
796
|
+
/**
|
|
797
|
+
* Internal Symbol an adapter may set to a callback that the framework invokes
|
|
798
|
+
* immediately before it reads the request body — and only once it has decided
|
|
799
|
+
* the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
|
|
800
|
+
*
|
|
801
|
+
* It exists for `Expect: 100-continue`. Node answers the interim `100` to
|
|
802
|
+
* anyone who asks, which solicits a body the framework may be about to refuse:
|
|
803
|
+
* a route with a body schema and a declared `Content-Length` over the limit is
|
|
804
|
+
* rejected before a byte is read, so the `100` invited megabytes that could
|
|
805
|
+
* only be discarded. The adapter defers its `writeContinue()` into this hook so
|
|
806
|
+
* the invitation tracks the framework's own decision.
|
|
807
|
+
*
|
|
808
|
+
* The framework's read decision has to be the trigger, because it is the only
|
|
809
|
+
* thing that makes the outcome independent of the `Expect` header. `Expect` is
|
|
810
|
+
* a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
|
|
811
|
+
* time against `bodyLimitBytes` instead looks equivalent but is not, since that
|
|
812
|
+
* limit is only applied where a body is parsed — a route declaring no body
|
|
813
|
+
* schema never applies it, so the same request answered `413` with `Expect` and
|
|
814
|
+
* `200` without.
|
|
815
|
+
*
|
|
816
|
+
* Adapter-facing only; userland code should not depend on it.
|
|
817
|
+
*
|
|
818
|
+
* @since 1.0.0-rc.9
|
|
819
|
+
*/
|
|
820
|
+
export declare const DALOY_REQUEST_BODY_SOLICIT: unique symbol;
|
|
796
821
|
/**
|
|
797
822
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
798
823
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
package/dist/app.js
CHANGED
|
@@ -136,6 +136,8 @@ const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
|
|
|
136
136
|
*/
|
|
137
137
|
const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
|
|
138
138
|
const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
|
|
139
|
+
const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
|
|
140
|
+
const EARLY_REJECTION_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
|
|
139
141
|
/**
|
|
140
142
|
* Apply a topology-aware security preset on top of caller-supplied
|
|
141
143
|
* options. Returns a new options object where preset defaults fill in
|
|
@@ -222,6 +224,31 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
|
222
224
|
* adapters can opt in; not part of the userland API surface.
|
|
223
225
|
*/
|
|
224
226
|
export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
|
|
227
|
+
/**
|
|
228
|
+
* Internal Symbol an adapter may set to a callback that the framework invokes
|
|
229
|
+
* immediately before it reads the request body — and only once it has decided
|
|
230
|
+
* the body is both wanted and within {@link AppOptions.bodyLimitBytes}.
|
|
231
|
+
*
|
|
232
|
+
* It exists for `Expect: 100-continue`. Node answers the interim `100` to
|
|
233
|
+
* anyone who asks, which solicits a body the framework may be about to refuse:
|
|
234
|
+
* a route with a body schema and a declared `Content-Length` over the limit is
|
|
235
|
+
* rejected before a byte is read, so the `100` invited megabytes that could
|
|
236
|
+
* only be discarded. The adapter defers its `writeContinue()` into this hook so
|
|
237
|
+
* the invitation tracks the framework's own decision.
|
|
238
|
+
*
|
|
239
|
+
* The framework's read decision has to be the trigger, because it is the only
|
|
240
|
+
* thing that makes the outcome independent of the `Expect` header. `Expect` is
|
|
241
|
+
* a hint about *when* to send the body (RFC 9110 §10.1.1); refusing at header
|
|
242
|
+
* time against `bodyLimitBytes` instead looks equivalent but is not, since that
|
|
243
|
+
* limit is only applied where a body is parsed — a route declaring no body
|
|
244
|
+
* schema never applies it, so the same request answered `413` with `Expect` and
|
|
245
|
+
* `200` without.
|
|
246
|
+
*
|
|
247
|
+
* Adapter-facing only; userland code should not depend on it.
|
|
248
|
+
*
|
|
249
|
+
* @since 1.0.0-rc.9
|
|
250
|
+
*/
|
|
251
|
+
export const DALOY_REQUEST_BODY_SOLICIT = Symbol.for("daloyjs.request.bodySolicit");
|
|
225
252
|
/**
|
|
226
253
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
227
254
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -1028,6 +1055,29 @@ export class App {
|
|
|
1028
1055
|
this.bootGuard.error = err;
|
|
1029
1056
|
throw err;
|
|
1030
1057
|
}
|
|
1058
|
+
// Guard 3b: a stored-response layer mounted ahead of a request budget.
|
|
1059
|
+
// `responseCache()` and `idempotency()` both answer from `beforeHandle` and
|
|
1060
|
+
// end the hook chain, and `rateLimit()` / `loginThrottle()` enforce from that
|
|
1061
|
+
// same phase — so a limiter mounted behind either one never counts the
|
|
1062
|
+
// requests it serves. Measured: `rateLimit({ max: 2 })` behind a cache or a
|
|
1063
|
+
// replay admitted six of six. The budget silently becomes infinite for
|
|
1064
|
+
// exactly the traffic that repeats most, which is what the limit was written
|
|
1065
|
+
// for. Same shape as the responseCache-ahead-of-gates finding, and the reason
|
|
1066
|
+
// the five network-identity gates moved to `preBody`; `rateLimit` cannot
|
|
1067
|
+
// follow them there because its `keyGenerator` is caller-supplied and may
|
|
1068
|
+
// read `ctx.state`, so the unsafe order is refused instead.
|
|
1069
|
+
const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
|
|
1070
|
+
if (replayBeforeBudget !== undefined && this.bootGuard.error === undefined) {
|
|
1071
|
+
this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
|
|
1072
|
+
`${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
|
|
1073
|
+
`effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
|
|
1074
|
+
`replay returns a response and ends the chain before the limiter counts the request — ` +
|
|
1075
|
+
`the declared budget is never spent on repeat traffic and is effectively unlimited. ` +
|
|
1076
|
+
`Register rateLimit() first — as a global hook (new App({ hooks: rateLimit(...) })) or ` +
|
|
1077
|
+
`an earlier app.use(...) — so every request is counted before a stored response can ` +
|
|
1078
|
+
`short-circuit it. See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
1079
|
+
throw this.bootGuard.error;
|
|
1080
|
+
}
|
|
1031
1081
|
// Guard 4: session() + state-changing route without csrf().
|
|
1032
1082
|
if (this.options.csrf === "off")
|
|
1033
1083
|
return;
|
|
@@ -3185,6 +3235,12 @@ function securityMarkersFromHooks(layers) {
|
|
|
3185
3235
|
// to tell whether the cache reads state before tenancy has written it.
|
|
3186
3236
|
let cacheIndex = -1;
|
|
3187
3237
|
let tenancyIndex = -1;
|
|
3238
|
+
// First stored-response layer of either kind, and the first request-budget
|
|
3239
|
+
// layer. Only the earliest of each matters: if any replay precedes any budget
|
|
3240
|
+
// hook, that budget is preemptable.
|
|
3241
|
+
let replayIndex = -1;
|
|
3242
|
+
let replayName = "";
|
|
3243
|
+
let budgetIndex = -1;
|
|
3188
3244
|
for (let i = 0; i < layers.length; i++) {
|
|
3189
3245
|
const record = layers[i];
|
|
3190
3246
|
if (record[SESSION_HOOK_MARKER] === true)
|
|
@@ -3197,12 +3253,25 @@ function securityMarkersFromHooks(layers) {
|
|
|
3197
3253
|
cacheIndex = i;
|
|
3198
3254
|
if (tenancyIndex === -1 && record[TENANCY_HOOK_MARKER] === true)
|
|
3199
3255
|
tenancyIndex = i;
|
|
3256
|
+
if (replayIndex === -1) {
|
|
3257
|
+
if (record[RESPONSE_CACHE_HOOK_MARKER] === true) {
|
|
3258
|
+
replayIndex = i;
|
|
3259
|
+
replayName = "responseCache()";
|
|
3260
|
+
}
|
|
3261
|
+
else if (record[IDEMPOTENCY_HOOK_MARKER] === true) {
|
|
3262
|
+
replayIndex = i;
|
|
3263
|
+
replayName = "idempotency()";
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
if (budgetIndex === -1 && Array.isArray(record[EARLY_REJECTION_MARKER]))
|
|
3267
|
+
budgetIndex = i;
|
|
3200
3268
|
}
|
|
3201
3269
|
return {
|
|
3202
3270
|
hasSession,
|
|
3203
3271
|
hasCsrf,
|
|
3204
3272
|
hasAuth,
|
|
3205
3273
|
cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
|
|
3274
|
+
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex ? replayName : null,
|
|
3206
3275
|
};
|
|
3207
3276
|
}
|
|
3208
3277
|
function isStateChangingMethod(method) {
|
|
@@ -3718,6 +3787,21 @@ function validateContext(ctx, def, opts) {
|
|
|
3718
3787
|
if (!allowed.some((a) => ct.includes(a))) {
|
|
3719
3788
|
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3720
3789
|
}
|
|
3790
|
+
// Refuse an over-limit *declared* length here, before soliciting the body.
|
|
3791
|
+
// `readBodyLimited` already makes the identical check on the identical
|
|
3792
|
+
// boundary, so this changes no outcome — it is load-bearing purely for
|
|
3793
|
+
// ordering, so that an adapter deferring `Expect: 100-continue` (see
|
|
3794
|
+
// {@link DALOY_REQUEST_BODY_SOLICIT}) never invites bytes this request was
|
|
3795
|
+
// always going to be refused for. Kept below the content-type check so a
|
|
3796
|
+
// wrong media type still answers `415` rather than `413`, as before.
|
|
3797
|
+
const declared = request.headers.get("content-length");
|
|
3798
|
+
if (declared !== null) {
|
|
3799
|
+
const declaredBytes = Number(declared);
|
|
3800
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > opts.bodyLimitBytes) {
|
|
3801
|
+
throw new PayloadTooLargeError(opts.bodyLimitBytes);
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
request[DALOY_REQUEST_BODY_SOLICIT]?.();
|
|
3721
3805
|
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
|
|
3722
3806
|
if (isPromiseLike(raw))
|
|
3723
3807
|
return raw.then(validateBodyAndFinish);
|
package/dist/auto-ban.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The middleware is dependency-free and runtime-portable. It observes outgoing
|
|
12
12
|
* responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
|
|
13
13
|
* status produced by *any* later middleware or handler, not just its own) and
|
|
14
|
-
* enforces the ban in {@link "./types.js".Hooks.
|
|
14
|
+
* enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
|
|
15
15
|
* lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
|
|
16
16
|
* `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
|
|
17
17
|
* implementation for multi-instance deployments.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module
|
|
20
20
|
* @since 0.37.0
|
|
21
21
|
*/
|
|
22
|
-
import type {
|
|
22
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
23
23
|
/**
|
|
24
24
|
* One client's auto-ban bookkeeping. A record tracks the current strike count
|
|
25
25
|
* inside the rolling strike window, when that window expires, the timestamp the
|
|
@@ -128,8 +128,22 @@ export interface AutoBanOptions {
|
|
|
128
128
|
* Derive the client identity from `ctx`, or `undefined` to skip the request
|
|
129
129
|
* (fail-open — never banned, never counted). Defaults to the proxy-header
|
|
130
130
|
* resolver when {@link trustProxyHeaders} is set.
|
|
131
|
+
*
|
|
132
|
+
* Called first in `preBody`, where the gate is immune to mount order (see
|
|
133
|
+
* {@link IdentityGateContext}). If it returns `undefined` there, it is called
|
|
134
|
+
* again in `beforeHandle` — by then `session()` and other `beforeHandle` layers
|
|
135
|
+
* have populated `ctx.state`, so a generator keyed on a resolved session works
|
|
136
|
+
* rather than silently disabling the ban. Requests enforced by that second
|
|
137
|
+
* attempt are order-sensitive again, because `beforeHandle` is the phase a
|
|
138
|
+
* `responseCache()` hit short-circuits; key off headers, params or query where
|
|
139
|
+
* you can and the `preBody` pass handles it. Returning `undefined` from *both*
|
|
140
|
+
* still skips the request.
|
|
141
|
+
*
|
|
142
|
+
* `ctx.body` is not available in either phase — `preBody` runs before parsing,
|
|
143
|
+
* and the type reflects that. Derive the key from the request line, headers, or
|
|
144
|
+
* state instead.
|
|
131
145
|
*/
|
|
132
|
-
keyGenerator?: (ctx:
|
|
146
|
+
keyGenerator?: (ctx: IdentityGateContext) => string | undefined;
|
|
133
147
|
/**
|
|
134
148
|
* Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
|
|
135
149
|
* default because those headers are client-spoofable unless every request
|
|
@@ -152,6 +166,32 @@ export interface AutoBanOptions {
|
|
|
152
166
|
* [1, 64]; validated at construction.
|
|
153
167
|
*/
|
|
154
168
|
trustedHops?: number;
|
|
169
|
+
/**
|
|
170
|
+
* What to do when the default key generator cannot resolve a forwarded
|
|
171
|
+
* identity — the request carried no `X-Forwarded-For`, or a chain shorter than
|
|
172
|
+
* {@link trustedHops} declares.
|
|
173
|
+
*
|
|
174
|
+
* - `"peer"` (default) — fall back to the immediate TCP peer address, in its
|
|
175
|
+
* own `peer:` keyspace. The peer cannot be spoofed, and a request that
|
|
176
|
+
* skipped the declared proxy chain came *from* that peer, so strikes are
|
|
177
|
+
* attributed to the real origin of the traffic.
|
|
178
|
+
* - `"skip"` — never count and never ban such a request.
|
|
179
|
+
*
|
|
180
|
+
* `"peer"` is the default because `"skip"` is a silent bypass: an attacker who
|
|
181
|
+
* can reach the origin directly gets unlimited strikes simply by omitting a
|
|
182
|
+
* header. Choose `"skip"` only when unresolved requests are known-benign and
|
|
183
|
+
* arrive from a shared address — for instance a load balancer that does not
|
|
184
|
+
* always set `X-Forwarded-For`, where every such request would otherwise share
|
|
185
|
+
* the balancer's single `peer:` bucket and a few `401`s could ban the lot.
|
|
186
|
+
* Prefer fixing the proxy configuration over choosing `"skip"`.
|
|
187
|
+
*
|
|
188
|
+
* Ignored when {@link keyGenerator} is supplied — a custom generator owns its
|
|
189
|
+
* own unresolved-identity posture, and returning `undefined` from it still
|
|
190
|
+
* means skip.
|
|
191
|
+
*
|
|
192
|
+
* @since 1.0.0-rc.8
|
|
193
|
+
*/
|
|
194
|
+
onUnresolvedIdentity?: "peer" | "skip";
|
|
155
195
|
/** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
|
|
156
196
|
store?: AutoBanStore;
|
|
157
197
|
/**
|
|
@@ -201,8 +241,12 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
|
|
|
201
241
|
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
|
|
202
242
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
203
243
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
204
|
-
* ban the whole world at once.
|
|
205
|
-
*
|
|
244
|
+
* ban the whole world at once. When the default generator cannot resolve a
|
|
245
|
+
* forwarded identity — no `X-Forwarded-For`, or a chain shorter than
|
|
246
|
+
* {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
|
|
247
|
+
* unspoofable TCP peer instead of being discarded; see
|
|
248
|
+
* {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
|
|
249
|
+
* returns `undefined` still skips the request.
|
|
206
250
|
*
|
|
207
251
|
* @example
|
|
208
252
|
* ```ts
|
package/dist/auto-ban.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The middleware is dependency-free and runtime-portable. It observes outgoing
|
|
12
12
|
* responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
|
|
13
13
|
* status produced by *any* later middleware or handler, not just its own) and
|
|
14
|
-
* enforces the ban in {@link "./types.js".Hooks.
|
|
14
|
+
* enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
|
|
15
15
|
* lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
|
|
16
16
|
* `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
|
|
17
17
|
* implementation for multi-instance deployments.
|
|
@@ -20,7 +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
|
+
import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } 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;
|
|
@@ -90,9 +90,32 @@ function assertPositiveInteger(name, value) {
|
|
|
90
90
|
* trusted proxy hops from the right of `X-Forwarded-For` (falling back to
|
|
91
91
|
* `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
|
|
92
92
|
* {@link resolveForwardedClientIp}.
|
|
93
|
+
*
|
|
94
|
+
* When the forwarded chain cannot satisfy the declaration,
|
|
95
|
+
* {@link resolveForwardedClientIp} returns `undefined` — correct for *identity*,
|
|
96
|
+
* because such a request never traversed the declared topology. For *abuse
|
|
97
|
+
* accounting* that answer used to mean "skip", which handed an attacker unlimited
|
|
98
|
+
* strikes for free: reach the origin directly, past the CDN that appends the
|
|
99
|
+
* header, and every failed credential attempt went uncounted.
|
|
100
|
+
*
|
|
101
|
+
* So the fallback is the immediate TCP peer, prefixed to keep it in its own
|
|
102
|
+
* keyspace. The peer address cannot be spoofed — it is the socket actually
|
|
103
|
+
* talking to the adapter — and in exactly the direct-to-origin case that
|
|
104
|
+
* produced the bypass, the peer *is* the attacker, so accounting becomes precise
|
|
105
|
+
* rather than absent. Set `onUnresolvedIdentity: "skip"` to restore the previous
|
|
106
|
+
* behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
|
|
107
|
+
* the right call.
|
|
93
108
|
*/
|
|
94
|
-
function forwardedKey(hops) {
|
|
95
|
-
return (ctx) =>
|
|
109
|
+
function forwardedKey(hops, peerFallback) {
|
|
110
|
+
return (ctx) => {
|
|
111
|
+
const forwarded = resolveForwardedClientIp(ctx.request, hops);
|
|
112
|
+
if (forwarded !== undefined)
|
|
113
|
+
return forwarded;
|
|
114
|
+
if (!peerFallback)
|
|
115
|
+
return undefined;
|
|
116
|
+
const peer = readRemoteAddress(ctx);
|
|
117
|
+
return peer === undefined ? undefined : `peer:${peer}`;
|
|
118
|
+
};
|
|
96
119
|
}
|
|
97
120
|
/**
|
|
98
121
|
* Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
|
|
@@ -103,8 +126,12 @@ function forwardedKey(hops) {
|
|
|
103
126
|
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
|
|
104
127
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
105
128
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
106
|
-
* ban the whole world at once.
|
|
107
|
-
*
|
|
129
|
+
* ban the whole world at once. When the default generator cannot resolve a
|
|
130
|
+
* forwarded identity — no `X-Forwarded-For`, or a chain shorter than
|
|
131
|
+
* {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
|
|
132
|
+
* unspoofable TCP peer instead of being discarded; see
|
|
133
|
+
* {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
|
|
134
|
+
* returns `undefined` still skips the request.
|
|
108
135
|
*
|
|
109
136
|
* @example
|
|
110
137
|
* ```ts
|
|
@@ -150,12 +177,16 @@ export function autoBan(opts = {}) {
|
|
|
150
177
|
}
|
|
151
178
|
const watch = new Set(watchStatuses);
|
|
152
179
|
const hops = resolveForwardedTrust("autoBan()", opts);
|
|
180
|
+
const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
|
|
181
|
+
if (onUnresolved !== "peer" && onUnresolved !== "skip") {
|
|
182
|
+
throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
|
|
183
|
+
}
|
|
153
184
|
let keyOf;
|
|
154
185
|
if (opts.keyGenerator) {
|
|
155
186
|
keyOf = opts.keyGenerator;
|
|
156
187
|
}
|
|
157
188
|
else if (hops !== undefined) {
|
|
158
|
-
keyOf = forwardedKey(hops);
|
|
189
|
+
keyOf = forwardedKey(hops, onUnresolved === "peer");
|
|
159
190
|
}
|
|
160
191
|
else {
|
|
161
192
|
throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
|
|
@@ -175,23 +206,40 @@ export function autoBan(opts = {}) {
|
|
|
175
206
|
store = shared;
|
|
176
207
|
}
|
|
177
208
|
const prefix = `${groupId}:`;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the identity, stash the key for `onSend`, and reject an active ban.
|
|
211
|
+
*
|
|
212
|
+
* Shared by the `preBody` gate and the `beforeHandle` fallback below so both
|
|
213
|
+
* phases enforce identically. Returns `true` once an identity was found, so
|
|
214
|
+
* the fallback knows whether `preBody` already handled the request.
|
|
215
|
+
*/
|
|
216
|
+
const enforce = async (ctx) => {
|
|
217
|
+
const identity = keyOf(ctx);
|
|
218
|
+
if (identity === undefined)
|
|
219
|
+
return false;
|
|
220
|
+
const key = `${prefix}${identity}`;
|
|
221
|
+
const state = ctx.state;
|
|
222
|
+
state[STATE_KEY] = key;
|
|
223
|
+
const record = await store.get(key);
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
if (record && record.bannedUntilMs > now) {
|
|
226
|
+
state[STATE_REJECTED] = true;
|
|
227
|
+
if (banStatus === 403)
|
|
228
|
+
throw new ForbiddenError(message);
|
|
229
|
+
const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
|
|
230
|
+
throw new TooManyRequestsError(retryAfter ? retry : undefined);
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
const hooks = {
|
|
235
|
+
// `preBody`, not `beforeHandle`: the ban check must not be preemptable by an
|
|
236
|
+
// earlier `beforeHandle` middleware that short-circuits — a
|
|
237
|
+
// `responseCache()` HIT mounted above it would serve a banned client the
|
|
238
|
+
// cached body, so the ban would only ever apply to uncached routes.
|
|
239
|
+
// `preBody` always runs before any `beforeHandle`. Strike accounting stays in
|
|
240
|
+
// `onSend`, which observes the final status either way.
|
|
241
|
+
async preBody(ctx) {
|
|
242
|
+
await enforce(ctx);
|
|
195
243
|
return undefined;
|
|
196
244
|
},
|
|
197
245
|
async onSend(res, ctx) {
|
|
@@ -227,4 +275,31 @@ export function autoBan(opts = {}) {
|
|
|
227
275
|
return undefined;
|
|
228
276
|
},
|
|
229
277
|
};
|
|
278
|
+
// A custom `keyGenerator` may legitimately be unable to answer in `preBody` —
|
|
279
|
+
// typically because it reads state a `beforeHandle` layer resolves, such as
|
|
280
|
+
// `session()`. Without a second attempt that request gets no identity, so
|
|
281
|
+
// `onSend` finds no key and records no strike: the ban silently never arms.
|
|
282
|
+
// That is a worse failure than the ordering hazard the phase move closed, so
|
|
283
|
+
// retry in `beforeHandle` when, and only when, `preBody` came up empty.
|
|
284
|
+
//
|
|
285
|
+
// The default forwarded resolver never needs this — `onUnresolvedIdentity`
|
|
286
|
+
// already falls back to the TCP peer — so the hook is registered only for a
|
|
287
|
+
// custom generator and the common path pays nothing.
|
|
288
|
+
//
|
|
289
|
+
// Residual, deliberately accepted: a request enforced by this fallback IS
|
|
290
|
+
// order-sensitive again, because `beforeHandle` is the phase a
|
|
291
|
+
// `responseCache()` hit short-circuits. It applies solely to requests whose
|
|
292
|
+
// identity could not be resolved earlier, and enforcing late beats not
|
|
293
|
+
// enforcing at all. Resolve identity from headers/params/query where you can
|
|
294
|
+
// and `preBody` handles it, immune to mount order.
|
|
295
|
+
if (opts.keyGenerator) {
|
|
296
|
+
hooks.beforeHandle = async (ctx) => {
|
|
297
|
+
const state = ctx.state;
|
|
298
|
+
if (state[STATE_KEY] !== undefined)
|
|
299
|
+
return undefined; // preBody had it
|
|
300
|
+
await enforce(ctx);
|
|
301
|
+
return undefined;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return hooks;
|
|
230
305
|
}
|
package/dist/bot-guard.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
* @since 0.37.0
|
|
37
37
|
*/
|
|
38
|
-
import type {
|
|
38
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
39
39
|
/**
|
|
40
40
|
* Pluggable DNS resolver used to verify declared crawlers. The default
|
|
41
41
|
* implementation lazily imports `node:dns/promises`; provide your own on
|
|
@@ -150,7 +150,7 @@ export interface BotGuardOptions {
|
|
|
150
150
|
/**
|
|
151
151
|
* Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
|
|
152
152
|
*/
|
|
153
|
-
resolveIp?: (ctx:
|
|
153
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
154
154
|
/**
|
|
155
155
|
* Custom DNS resolver for crawler verification. Defaults to a lazy
|
|
156
156
|
* `node:dns/promises`-backed resolver.
|
package/dist/bot-guard.js
CHANGED
|
@@ -260,7 +260,12 @@ export function botGuard(opts = {}) {
|
|
|
260
260
|
throw new ForbiddenError(message);
|
|
261
261
|
};
|
|
262
262
|
return {
|
|
263
|
-
|
|
263
|
+
// `preBody`, not `beforeHandle`: a bot gate that short-circuits from
|
|
264
|
+
// `beforeHandle` loses to any earlier `beforeHandle` middleware that returns
|
|
265
|
+
// a Response first — a `responseCache()` HIT mounted above it would hand a
|
|
266
|
+
// blocked scraper the cached body. `preBody` always precedes `beforeHandle`,
|
|
267
|
+
// so the gate holds regardless of mount order.
|
|
268
|
+
async preBody(ctx) {
|
|
264
269
|
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
265
270
|
// Allowlist wins over every other rule.
|
|
266
271
|
if (allowed.length > 0 && matchesUserAgent(ua, allowed))
|
package/dist/geo-block.d.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* @module
|
|
25
25
|
* @since 0.37.0
|
|
26
26
|
*/
|
|
27
|
-
import type {
|
|
27
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
28
28
|
/**
|
|
29
29
|
* Why a request was (or would have been) blocked by {@link geoBlock}.
|
|
30
30
|
*
|
|
@@ -57,7 +57,7 @@ export interface GeoBlockDecision {
|
|
|
57
57
|
*
|
|
58
58
|
* @since 0.37.0
|
|
59
59
|
*/
|
|
60
|
-
export type CountryFromContext = (ctx:
|
|
60
|
+
export type CountryFromContext = (ctx: IdentityGateContext) => string | undefined | null | Promise<string | undefined | null>;
|
|
61
61
|
/**
|
|
62
62
|
* Operator-supplied IP → country lookup (e.g. a MaxMind reader). Return
|
|
63
63
|
* `undefined`/`null`/`""` when the IP cannot be mapped to a country.
|
|
@@ -110,7 +110,7 @@ export interface GeoBlockOptions {
|
|
|
110
110
|
* default Daloy fails closed because Web-standard `Request` objects do not
|
|
111
111
|
* expose the peer address. Ignored when `resolveCountry` is used.
|
|
112
112
|
*/
|
|
113
|
-
resolveIp?: (ctx:
|
|
113
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
114
114
|
/**
|
|
115
115
|
* Read `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Defaults
|
|
116
116
|
* to `false` because those headers are client-spoofable unless every
|
package/dist/geo-block.js
CHANGED
|
@@ -107,7 +107,13 @@ export function geoBlock(opts) {
|
|
|
107
107
|
const hops = resolveForwardedTrust("geoBlock()", opts);
|
|
108
108
|
const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
|
|
109
109
|
return {
|
|
110
|
-
|
|
110
|
+
// Runs in `preBody`, not `beforeHandle`. A `beforeHandle` hook that returns
|
|
111
|
+
// a Response ends the chain, so a country gate in that phase is preempted by
|
|
112
|
+
// any earlier `beforeHandle` middleware that short-circuits — a
|
|
113
|
+
// `responseCache()` HIT above it would serve a denied country the cached
|
|
114
|
+
// body. `preBody` always precedes `beforeHandle`, which makes the gate
|
|
115
|
+
// immune to mount order. See {@link geoBlock}'s security note.
|
|
116
|
+
async preBody(ctx) {
|
|
111
117
|
let ip;
|
|
112
118
|
let rawCountry;
|
|
113
119
|
if (resolveCountry) {
|