@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.5
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 +14 -12
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +104 -19
- package/dist/app.d.ts +25 -3
- package/dist/app.js +113 -39
- package/dist/bot-guard.js +30 -3
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +25 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +86 -26
- package/package.json +5 -4
package/dist/adapters/node.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
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, } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
|
|
8
|
+
import { BadRequestError } from "../errors.js";
|
|
8
9
|
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
10
|
+
import { setConnInfo } from "../conn-info.js";
|
|
9
11
|
import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
|
|
10
12
|
/**
|
|
11
13
|
* Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}.
|
|
@@ -85,11 +87,23 @@ export function serve(app, opts = {}) {
|
|
|
85
87
|
server.on("upgrade", (req, socket, head) => {
|
|
86
88
|
wsSockets.add(socket);
|
|
87
89
|
socket.on("close", () => wsSockets.delete(socket));
|
|
88
|
-
|
|
90
|
+
// Safety net: a rejection here would otherwise be unhandled and, under
|
|
91
|
+
// the production crash-on-unhandledRejection posture, kill the process
|
|
92
|
+
// from a single malformed upgrade request.
|
|
93
|
+
handleUpgrade(app, req, socket, head, trustProxy).catch((err) => {
|
|
94
|
+
app.log.error({ err }, "WebSocket upgrade failed");
|
|
95
|
+
try {
|
|
96
|
+
writeUpgradeError(socket, 400, "Bad Request");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* socket already closed */
|
|
100
|
+
}
|
|
101
|
+
socket.destroy();
|
|
102
|
+
});
|
|
89
103
|
});
|
|
90
104
|
}
|
|
91
|
-
const
|
|
92
|
-
server.listen(
|
|
105
|
+
const requestedPort = opts.port ?? 3000;
|
|
106
|
+
server.listen(requestedPort, opts.hostname ?? "0.0.0.0");
|
|
93
107
|
// Kill idle keep-alive sockets immediately when draining begins.
|
|
94
108
|
// In-flight requests keep their socket because Node's
|
|
95
109
|
// `closeIdleConnections()` is a no-op for sockets with an in-flight request.
|
|
@@ -115,7 +129,16 @@ export function serve(app, opts = {}) {
|
|
|
115
129
|
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
116
130
|
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
117
131
|
}
|
|
118
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
server,
|
|
134
|
+
get port() {
|
|
135
|
+
const address = server.address();
|
|
136
|
+
return address !== null && typeof address === "object"
|
|
137
|
+
? address.port
|
|
138
|
+
: requestedPort;
|
|
139
|
+
},
|
|
140
|
+
close,
|
|
141
|
+
};
|
|
119
142
|
}
|
|
120
143
|
/**
|
|
121
144
|
* Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
|
|
@@ -135,6 +158,14 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
|
135
158
|
writeAdapterError(res, e);
|
|
136
159
|
return;
|
|
137
160
|
}
|
|
161
|
+
// Fulfil the conn-info contract: the immediate TCP peer, so
|
|
162
|
+
// `getConnInfo` / `resolveClientIp` / `behindProxy` and WAF client-IP
|
|
163
|
+
// attribution work on Node. Never derived from spoofable headers.
|
|
164
|
+
setConnInfo(request, {
|
|
165
|
+
remoteAddress: req.socket.remoteAddress,
|
|
166
|
+
remotePort: req.socket.remotePort,
|
|
167
|
+
tls: req.socket.encrypted === true,
|
|
168
|
+
});
|
|
138
169
|
attachClientCertificate(req, request);
|
|
139
170
|
const responseOrPromise = app.fetch(request);
|
|
140
171
|
if (responseOrPromise instanceof Promise) {
|
|
@@ -279,12 +310,15 @@ function writeMethodRefused(res) {
|
|
|
279
310
|
}
|
|
280
311
|
function writeAdapterError(res, e) {
|
|
281
312
|
if (!res.headersSent) {
|
|
282
|
-
|
|
313
|
+
const clientError = e instanceof BadRequestError;
|
|
314
|
+
res.statusCode = clientError ? 400 : 500;
|
|
283
315
|
res.setHeader("content-type", "application/problem+json");
|
|
284
316
|
res.end(JSON.stringify({
|
|
285
|
-
type:
|
|
286
|
-
|
|
287
|
-
|
|
317
|
+
type: clientError
|
|
318
|
+
? "https://daloyjs.dev/errors/bad-request"
|
|
319
|
+
: "https://daloyjs.dev/errors/internal",
|
|
320
|
+
title: clientError ? "Bad Request" : "Internal Server Error",
|
|
321
|
+
status: clientError ? 400 : 500,
|
|
288
322
|
}));
|
|
289
323
|
}
|
|
290
324
|
else {
|
|
@@ -307,9 +341,11 @@ function writeAdapterError(res, e) {
|
|
|
307
341
|
* - `instanceof Request` holds (prototype chain is re-rooted onto
|
|
308
342
|
* `Request.prototype`), and every WHATWG method/getter is overridden here,
|
|
309
343
|
* so nothing hits undici's brand-checked prototype accessors.
|
|
310
|
-
* - `signal` is
|
|
311
|
-
*
|
|
312
|
-
*
|
|
344
|
+
* - `signal` is a lazily-created per-instance `AbortSignal`. The framework
|
|
345
|
+
* aborts it (via the {@link DALOY_REQUEST_ABORT} hook) when the request
|
|
346
|
+
* exceeds `requestTimeoutMs`, so a handler that forwards `ctx.request.signal`
|
|
347
|
+
* to downstream `fetch`/DB calls sees them cancel on timeout. It is still NOT
|
|
348
|
+
* wired to client socket-disconnect — that teardown never fires the signal.
|
|
313
349
|
* - Passing this object directly to `fetch()` is not supported (undici
|
|
314
350
|
* brand-checks its input) — forward with `request.clone()` instead, which
|
|
315
351
|
* returns a real `Request`. This mirrors @hono/node-server's shim.
|
|
@@ -327,7 +363,7 @@ class LightRequest {
|
|
|
327
363
|
#headers;
|
|
328
364
|
#bodyBytes;
|
|
329
365
|
#real;
|
|
330
|
-
#
|
|
366
|
+
#controller;
|
|
331
367
|
constructor(url, method, headers, bodyBytes) {
|
|
332
368
|
this.#url = url;
|
|
333
369
|
this.#method = method;
|
|
@@ -355,10 +391,22 @@ class LightRequest {
|
|
|
355
391
|
return this.#headers;
|
|
356
392
|
}
|
|
357
393
|
get signal() {
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
394
|
+
// Lazily created so only handlers that actually read `signal` pay for the
|
|
395
|
+
// controller. The framework aborts it via the DALOY_REQUEST_ABORT hook
|
|
396
|
+
// below when the request exceeds `requestTimeoutMs`; client
|
|
397
|
+
// socket-disconnect is not wired into it.
|
|
398
|
+
return (this.#controller ??= new AbortController()).signal;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Framework abort hook ({@link DALOY_REQUEST_ABORT}). Invoked by the core on
|
|
402
|
+
* request timeout so `ctx.request.signal` fires for cooperative teardown.
|
|
403
|
+
* A no-op when no handler ever read `signal` — there is no controller to
|
|
404
|
+
* abort and nothing observing it.
|
|
405
|
+
*
|
|
406
|
+
* @param reason - Abort reason surfaced on `signal.reason` (a `TimeoutError`).
|
|
407
|
+
*/
|
|
408
|
+
[DALOY_REQUEST_ABORT](reason) {
|
|
409
|
+
this.#controller?.abort(reason);
|
|
362
410
|
}
|
|
363
411
|
get body() {
|
|
364
412
|
return this.#materialize().body;
|
|
@@ -506,6 +554,13 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
506
554
|
const proto = forwardedProto ??
|
|
507
555
|
(req.socket.encrypted ? "https" : "http");
|
|
508
556
|
const url = `${proto}://${host}${normalizeRequestTarget(req.url)}`;
|
|
557
|
+
// Reject malformed Host / request-target combinations at the adapter
|
|
558
|
+
// boundary instead of letting the invalid URL propagate as a 500 later.
|
|
559
|
+
// URL.canParse applies WHATWG validation without allocating and immediately
|
|
560
|
+
// discarding a URL object on every request.
|
|
561
|
+
if (!URL.canParse(url)) {
|
|
562
|
+
throw new BadRequestError("Invalid request target or Host header");
|
|
563
|
+
}
|
|
509
564
|
// Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
|
|
510
565
|
// of the parsed `req.headers` object. This matches @hono/node-server's
|
|
511
566
|
// `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
|
|
@@ -578,7 +633,21 @@ function normalizeRequestTarget(target) {
|
|
|
578
633
|
}
|
|
579
634
|
function sendWebResponse(res, out) {
|
|
580
635
|
out.statusCode = res.status;
|
|
581
|
-
|
|
636
|
+
// `Headers.forEach` yields each `Set-Cookie` as a separate callback while
|
|
637
|
+
// `ServerResponse.setHeader` overwrites repeated keys — copying naively
|
|
638
|
+
// keeps only the LAST cookie (e.g. dropping the session cookie when
|
|
639
|
+
// `csrf()` also sets its token cookie). Collect them via `getSetCookie()`
|
|
640
|
+
// and set the array once so every cookie reaches the wire.
|
|
641
|
+
let hasSetCookie = false;
|
|
642
|
+
res.headers.forEach((v, k) => {
|
|
643
|
+
if (k === "set-cookie") {
|
|
644
|
+
hasSetCookie = true;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
out.setHeader(k, v);
|
|
648
|
+
});
|
|
649
|
+
if (hasSetCookie)
|
|
650
|
+
out.setHeader("set-cookie", res.headers.getSetCookie());
|
|
582
651
|
// Fast-path: response was produced by serializeResult and carries the raw
|
|
583
652
|
// body bytes via the DALOY_RAW_BODY Symbol. Skip arrayBuffer() and the
|
|
584
653
|
// reader-loop microtask churn entirely for buffer-backed responses.
|
|
@@ -661,7 +730,18 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
|
|
|
661
730
|
: undefined;
|
|
662
731
|
const proto = forwardedProto ??
|
|
663
732
|
(req.socket.encrypted ? "https" : "http");
|
|
664
|
-
|
|
733
|
+
// A malformed `Host` header (e.g. containing a space) reaches this point:
|
|
734
|
+
// Node's HTTP parser accepts it and fires `upgrade`, but WHATWG URL
|
|
735
|
+
// parsing throws. Reject it as the client error it is instead of letting
|
|
736
|
+
// the throw escape the adapter.
|
|
737
|
+
let url;
|
|
738
|
+
try {
|
|
739
|
+
url = new URL(`${proto}://${host}${req.url ?? "/"}`);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
writeUpgradeError(socket, 400, "Bad Request");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
665
745
|
const match = app.webSocketRoutes.find(url.pathname);
|
|
666
746
|
if (!match) {
|
|
667
747
|
writeUpgradeError(socket, 404, "Not Found");
|
|
@@ -682,6 +762,11 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
|
|
|
682
762
|
method: "GET",
|
|
683
763
|
headers,
|
|
684
764
|
});
|
|
765
|
+
setConnInfo(request, {
|
|
766
|
+
remoteAddress: req.socket.remoteAddress,
|
|
767
|
+
remotePort: req.socket.remotePort,
|
|
768
|
+
tls: req.socket.encrypted === true,
|
|
769
|
+
});
|
|
685
770
|
const ctx = {
|
|
686
771
|
request,
|
|
687
772
|
params: match.params,
|
package/dist/app.d.ts
CHANGED
|
@@ -78,7 +78,12 @@ export interface AppOptions {
|
|
|
78
78
|
bodyLimitBytes?: number;
|
|
79
79
|
/** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
|
|
80
80
|
allowedContentTypes?: string[];
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to
|
|
83
|
+
* disable. On timeout the framework aborts `ctx.request.signal` (cooperative
|
|
84
|
+
* cancellation of downstream I/O) and responds `408`; see
|
|
85
|
+
* {@link RequestTimeoutError}. It does not forcibly stop CPU-bound work.
|
|
86
|
+
*/
|
|
82
87
|
requestTimeoutMs?: number;
|
|
83
88
|
/**
|
|
84
89
|
* Maximum number of distinct request header fields accepted before the
|
|
@@ -771,6 +776,23 @@ export declare const DALOY_RAW_BODY: unique symbol;
|
|
|
771
776
|
* so first-party adapters can opt in; not part of the userland API surface.
|
|
772
777
|
*/
|
|
773
778
|
export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
779
|
+
/**
|
|
780
|
+
* Internal Symbol an adapter sets (on its request shim) to expose the request's
|
|
781
|
+
* abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
|
|
782
|
+
* backing `request.signal`. The core invokes it when a request exceeds
|
|
783
|
+
* {@link AppOptions.requestTimeoutMs} so a handler that forwarded
|
|
784
|
+
* `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
|
|
785
|
+
* calls cancel — cooperative teardown, since single-threaded JS cannot preempt
|
|
786
|
+
* a running handler.
|
|
787
|
+
*
|
|
788
|
+
* The hook is invoked as a method on the request (`this` stays bound to the
|
|
789
|
+
* shim) so it can reach the shim's private controller. Absent on runtimes
|
|
790
|
+
* whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
|
|
791
|
+
* on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
|
|
792
|
+
* and the timeout still resolves as a `408`. Module-public so first-party
|
|
793
|
+
* adapters can opt in; not part of the userland API surface.
|
|
794
|
+
*/
|
|
795
|
+
export declare const DALOY_REQUEST_ABORT: unique symbol;
|
|
774
796
|
/**
|
|
775
797
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
776
798
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -1687,8 +1709,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1687
1709
|
* "draining" signal); then the app waits up to `timeoutMs` for in-flight
|
|
1688
1710
|
* requests to settle; finally, {@link App.onClose} cleanups run.
|
|
1689
1711
|
*
|
|
1690
|
-
*
|
|
1691
|
-
* Call it manually from custom runtimes or integration tests.
|
|
1712
|
+
* The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
|
|
1713
|
+
* `SIGTERM`. Call it manually from custom runtimes or integration tests.
|
|
1692
1714
|
*
|
|
1693
1715
|
* @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
|
|
1694
1716
|
* @param reason - Optional human-readable reason forwarded to listeners.
|
package/dist/app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Router } from "./router.js";
|
|
2
2
|
import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
3
3
|
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
|
-
import { readBodyLimited, safeJsonParseLimited, randomId,
|
|
5
|
-
import { createLogger, noopLogger } from "./logger.js";
|
|
4
|
+
import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
5
|
+
import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
|
|
6
6
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
7
|
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
8
8
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -196,6 +196,23 @@ export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
|
|
|
196
196
|
* so first-party adapters can opt in; not part of the userland API surface.
|
|
197
197
|
*/
|
|
198
198
|
export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
199
|
+
/**
|
|
200
|
+
* Internal Symbol an adapter sets (on its request shim) to expose the request's
|
|
201
|
+
* abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
|
|
202
|
+
* backing `request.signal`. The core invokes it when a request exceeds
|
|
203
|
+
* {@link AppOptions.requestTimeoutMs} so a handler that forwarded
|
|
204
|
+
* `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
|
|
205
|
+
* calls cancel — cooperative teardown, since single-threaded JS cannot preempt
|
|
206
|
+
* a running handler.
|
|
207
|
+
*
|
|
208
|
+
* The hook is invoked as a method on the request (`this` stays bound to the
|
|
209
|
+
* shim) so it can reach the shim's private controller. Absent on runtimes
|
|
210
|
+
* whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
|
|
211
|
+
* on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
|
|
212
|
+
* and the timeout still resolves as a `408`. Module-public so first-party
|
|
213
|
+
* adapters can opt in; not part of the userland API surface.
|
|
214
|
+
*/
|
|
215
|
+
export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
|
|
199
216
|
/**
|
|
200
217
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
201
218
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -1701,6 +1718,7 @@ export class App {
|
|
|
1701
1718
|
}));
|
|
1702
1719
|
this._coldPathHooksCache = undefined;
|
|
1703
1720
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1721
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1704
1722
|
this.route({
|
|
1705
1723
|
method: "GET",
|
|
1706
1724
|
path,
|
|
@@ -1711,7 +1729,7 @@ export class App {
|
|
|
1711
1729
|
acknowledgeNoResponseBodySchema: true,
|
|
1712
1730
|
handler: async ({ request }) => {
|
|
1713
1731
|
if (buckets && rateLimitConfig) {
|
|
1714
|
-
const key = healthRouteKey(request);
|
|
1732
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1715
1733
|
const now = Date.now();
|
|
1716
1734
|
const entry = buckets.get(key);
|
|
1717
1735
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1821,6 +1839,7 @@ export class App {
|
|
|
1821
1839
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1822
1840
|
}
|
|
1823
1841
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1842
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1824
1843
|
this.route({
|
|
1825
1844
|
method: "GET",
|
|
1826
1845
|
path,
|
|
@@ -1831,7 +1850,7 @@ export class App {
|
|
|
1831
1850
|
acknowledgeNoResponseBodySchema: true,
|
|
1832
1851
|
handler: async ({ request }) => {
|
|
1833
1852
|
if (buckets && rateLimitConfig) {
|
|
1834
|
-
const key = healthRouteKey(request);
|
|
1853
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1835
1854
|
const now = Date.now();
|
|
1836
1855
|
const entry = buckets.get(key);
|
|
1837
1856
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1895,6 +1914,7 @@ export class App {
|
|
|
1895
1914
|
}
|
|
1896
1915
|
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1897
1916
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1917
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1898
1918
|
const log = this.log;
|
|
1899
1919
|
// Only log report bodies when explicitly enabled. In
|
|
1900
1920
|
// production this is opt-in; in development the body is included by
|
|
@@ -1908,7 +1928,7 @@ export class App {
|
|
|
1908
1928
|
summary: "CSP / Reporting API violation receiver",
|
|
1909
1929
|
handler: async ({ request }) => {
|
|
1910
1930
|
if (buckets && rateLimitConfig) {
|
|
1911
|
-
const key = healthRouteKey(request);
|
|
1931
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1912
1932
|
const now = Date.now();
|
|
1913
1933
|
const entry = buckets.get(key);
|
|
1914
1934
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1944,7 +1964,7 @@ export class App {
|
|
|
1944
1964
|
if (parsed === undefined) {
|
|
1945
1965
|
throw new BadRequestError("Invalid JSON report body");
|
|
1946
1966
|
}
|
|
1947
|
-
const ip = healthRouteKey(request);
|
|
1967
|
+
const ip = healthRouteKey(request, trustProxyHeaders);
|
|
1948
1968
|
const userAgent = request.headers.get("user-agent");
|
|
1949
1969
|
try {
|
|
1950
1970
|
if (opts.onReport) {
|
|
@@ -2385,7 +2405,11 @@ export class App {
|
|
|
2385
2405
|
: baseLog.child({
|
|
2386
2406
|
requestId,
|
|
2387
2407
|
method: request.method,
|
|
2388
|
-
|
|
2408
|
+
// Never bind the raw request URL: query strings commonly carry
|
|
2409
|
+
// OAuth codes, API keys, and signed-URL tokens. sanitizeUrlForLog
|
|
2410
|
+
// keeps origin+path and redacts sensitive query values so 4xx/5xx
|
|
2411
|
+
// lines cannot become a credential sink under the field name `url`.
|
|
2412
|
+
url: sanitizeUrlForLog(request.url),
|
|
2389
2413
|
});
|
|
2390
2414
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
2391
2415
|
let ctx;
|
|
@@ -2394,9 +2418,10 @@ export class App {
|
|
|
2394
2418
|
let activeResponseHook = globalHooks.onResponse;
|
|
2395
2419
|
let activeSendHook = globalHooks.onSend;
|
|
2396
2420
|
try {
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2421
|
+
// Singleton-duplicate + reserved-prefix + header-count cap share ONE
|
|
2422
|
+
// Headers.forEach walk (assertInboundHeaderGuards) instead of a
|
|
2423
|
+
// three-Headers.get() pass plus a separate walk.
|
|
2424
|
+
assertInboundHeaderGuards(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
|
|
2400
2425
|
this.assertTrustProxyConfigured(request);
|
|
2401
2426
|
this.assertBootGuards();
|
|
2402
2427
|
if (globalHooks.onRequest !== undefined) {
|
|
@@ -2862,8 +2887,8 @@ export class App {
|
|
|
2862
2887
|
* "draining" signal); then the app waits up to `timeoutMs` for in-flight
|
|
2863
2888
|
* requests to settle; finally, {@link App.onClose} cleanups run.
|
|
2864
2889
|
*
|
|
2865
|
-
*
|
|
2866
|
-
* Call it manually from custom runtimes or integration tests.
|
|
2890
|
+
* The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
|
|
2891
|
+
* `SIGTERM`. Call it manually from custom runtimes or integration tests.
|
|
2867
2892
|
*
|
|
2868
2893
|
* @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
|
|
2869
2894
|
* @param reason - Optional human-readable reason forwarded to listeners.
|
|
@@ -2954,14 +2979,30 @@ function joinPath(a, b) {
|
|
|
2954
2979
|
const joined = `${left}${right}`;
|
|
2955
2980
|
return joined === "" ? "/" : joined;
|
|
2956
2981
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2982
|
+
/**
|
|
2983
|
+
* Rate-limit / attribution key for built-in observability routes
|
|
2984
|
+
* (`/healthz`, `/readyz`, `/metrics`, CSP report).
|
|
2985
|
+
*
|
|
2986
|
+
* Secure default: a single shared `"global"` bucket. Spoofable platform
|
|
2987
|
+
* headers (`X-Real-IP`, `Fly-Client-IP`) are only read when the app has an
|
|
2988
|
+
* explicit trusted-proxy posture (`trustProxy: true` or `behindProxy` set).
|
|
2989
|
+
* `X-Forwarded-For` is never used here — probes and scrapers often hit the
|
|
2990
|
+
* process directly, and a free-form XFF chain would let an attacker rotate
|
|
2991
|
+
* identities to bypass the cap.
|
|
2992
|
+
*
|
|
2993
|
+
* @param request - Inbound request.
|
|
2994
|
+
* @param trustProxyHeaders - When true, platform client-IP headers may be used.
|
|
2995
|
+
* @returns A stable string key for the in-memory rate-limit map.
|
|
2996
|
+
*/
|
|
2997
|
+
function healthRouteKey(request, trustProxyHeaders) {
|
|
2998
|
+
if (!trustProxyHeaders)
|
|
2999
|
+
return "global";
|
|
2963
3000
|
return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
|
|
2964
3001
|
}
|
|
3002
|
+
/** True when the app declared a trusted reverse-proxy posture. */
|
|
3003
|
+
function appTrustsProxyHeaders(options) {
|
|
3004
|
+
return options.trustProxy === true || options.behindProxy !== undefined;
|
|
3005
|
+
}
|
|
2965
3006
|
function corsOriginAllowsFromHooks(layers) {
|
|
2966
3007
|
const allows = [];
|
|
2967
3008
|
for (const hooks of layers) {
|
|
@@ -3335,34 +3376,32 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
3335
3376
|
}
|
|
3336
3377
|
function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
3337
3378
|
let final = res;
|
|
3338
|
-
const finish = (f) => {
|
|
3339
|
-
if (stripFingerprint) {
|
|
3340
|
-
f.headers.delete("server");
|
|
3341
|
-
f.headers.delete("x-powered-by");
|
|
3342
|
-
}
|
|
3343
|
-
if (hooks.onResponse !== undefined) {
|
|
3344
|
-
const onResponseResult = hooks.onResponse(f);
|
|
3345
|
-
if (isPromiseLike(onResponseResult)) {
|
|
3346
|
-
return onResponseResult.then(() => f);
|
|
3347
|
-
}
|
|
3348
|
-
}
|
|
3349
|
-
return f;
|
|
3350
|
-
};
|
|
3351
3379
|
if (hooks.onSend !== undefined) {
|
|
3352
3380
|
const sentResult = hooks.onSend(res, ctx);
|
|
3353
3381
|
if (isPromiseLike(sentResult)) {
|
|
3354
3382
|
return sentResult.then((sent) => {
|
|
3355
3383
|
if (sent instanceof Response)
|
|
3356
3384
|
final = sent;
|
|
3357
|
-
return
|
|
3385
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3358
3386
|
});
|
|
3359
3387
|
}
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3388
|
+
if (sentResult instanceof Response)
|
|
3389
|
+
final = sentResult;
|
|
3390
|
+
}
|
|
3391
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3392
|
+
}
|
|
3393
|
+
function finishFinalize(res, hooks, stripFingerprint) {
|
|
3394
|
+
if (stripFingerprint) {
|
|
3395
|
+
res.headers.delete("server");
|
|
3396
|
+
res.headers.delete("x-powered-by");
|
|
3397
|
+
}
|
|
3398
|
+
if (hooks.onResponse !== undefined) {
|
|
3399
|
+
const onResponseResult = hooks.onResponse(res);
|
|
3400
|
+
if (isPromiseLike(onResponseResult)) {
|
|
3401
|
+
return onResponseResult.then(() => res);
|
|
3363
3402
|
}
|
|
3364
3403
|
}
|
|
3365
|
-
return
|
|
3404
|
+
return res;
|
|
3366
3405
|
}
|
|
3367
3406
|
function isPromiseLike(value) {
|
|
3368
3407
|
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
@@ -3991,11 +4030,46 @@ function runHandler(def, ctx, requestTimeoutMs) {
|
|
|
3991
4030
|
if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
|
|
3992
4031
|
return result;
|
|
3993
4032
|
}
|
|
3994
|
-
return withTimeout(result, requestTimeoutMs);
|
|
4033
|
+
return withTimeout(result, requestTimeoutMs, ctx.request);
|
|
3995
4034
|
}
|
|
3996
|
-
|
|
4035
|
+
/**
|
|
4036
|
+
* Fire an adapter's request abort hook (see {@link DALOY_REQUEST_ABORT}) if the
|
|
4037
|
+
* request shim exposes one. Called as a method so `this` stays bound to the
|
|
4038
|
+
* request. A no-op when the hook is absent (platform-managed `Request.signal`
|
|
4039
|
+
* or a direct `app.fetch()` caller), so the caller must not depend on the
|
|
4040
|
+
* signal actually firing.
|
|
4041
|
+
*
|
|
4042
|
+
* @param request - The in-flight request whose signal should be aborted.
|
|
4043
|
+
* @param reason - Abort reason surfaced on `request.signal.reason`.
|
|
4044
|
+
*/
|
|
4045
|
+
function abortRequest(request, reason) {
|
|
4046
|
+
const hooked = request;
|
|
4047
|
+
hooked[DALOY_REQUEST_ABORT]?.(reason);
|
|
4048
|
+
}
|
|
4049
|
+
/**
|
|
4050
|
+
* Race a handler promise against the per-request timeout.
|
|
4051
|
+
*
|
|
4052
|
+
* On timeout the request's {@link DALOY_REQUEST_ABORT} hook is fired first —
|
|
4053
|
+
* aborting `request.signal` with a `TimeoutError` `DOMException` (the same
|
|
4054
|
+
* reason shape as `AbortSignal.timeout()`) so cooperative downstream I/O the
|
|
4055
|
+
* handler forwarded the signal to unwinds — and then the returned promise
|
|
4056
|
+
* rejects with a {@link RequestTimeoutError} so the client receives a `408`.
|
|
4057
|
+
* The handler promise itself keeps a rejection handler attached, so a late
|
|
4058
|
+
* settle (including the `AbortError` from the work it just cancelled) never
|
|
4059
|
+
* surfaces as an unhandled rejection.
|
|
4060
|
+
*
|
|
4061
|
+
* @typeParam T - The handler's resolved value type.
|
|
4062
|
+
* @param p - The handler (or hook chain) promise to bound.
|
|
4063
|
+
* @param ms - Timeout in milliseconds; assumed non-zero by the caller.
|
|
4064
|
+
* @param request - The in-flight request, used to fire the abort hook.
|
|
4065
|
+
* @returns A promise that settles with the handler result or a 408 timeout.
|
|
4066
|
+
*/
|
|
4067
|
+
function withTimeout(p, ms, request) {
|
|
3997
4068
|
return new Promise((resolve, reject) => {
|
|
3998
|
-
const t = setTimeout(() =>
|
|
4069
|
+
const t = setTimeout(() => {
|
|
4070
|
+
abortRequest(request, new DOMException(`Request exceeded ${ms}ms`, "TimeoutError"));
|
|
4071
|
+
reject(new RequestTimeoutError(ms));
|
|
4072
|
+
}, ms);
|
|
3999
4073
|
p.then((v) => {
|
|
4000
4074
|
clearTimeout(t);
|
|
4001
4075
|
resolve(v);
|
package/dist/bot-guard.js
CHANGED
|
@@ -76,8 +76,14 @@ function matchesUserAgent(ua, patterns) {
|
|
|
76
76
|
if (pattern && lower.includes(pattern.toLowerCase()))
|
|
77
77
|
return true;
|
|
78
78
|
}
|
|
79
|
-
else
|
|
80
|
-
|
|
79
|
+
else {
|
|
80
|
+
// Reset lastIndex so caller-supplied /g or /y regexes cannot flip-flop
|
|
81
|
+
// between match and miss across requests (intermittent allowlist bypass).
|
|
82
|
+
pattern.lastIndex = 0;
|
|
83
|
+
const hit = pattern.test(ua);
|
|
84
|
+
pattern.lastIndex = 0;
|
|
85
|
+
if (hit)
|
|
86
|
+
return true;
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
return false;
|
|
@@ -225,12 +231,28 @@ export function botGuard(opts = {}) {
|
|
|
225
231
|
};
|
|
226
232
|
const writeCache = (key, verified) => {
|
|
227
233
|
const now = Date.now();
|
|
234
|
+
// Move this key to the newest insertion slot on every (re)write. Eviction
|
|
235
|
+
// below is therefore FIFO over WRITE-recency (Map preserves insertion
|
|
236
|
+
// order), not true LRU: cache *reads* on the verification path do not
|
|
237
|
+
// reorder entries, so a frequently-read-but-never-rewritten key can still
|
|
238
|
+
// be evicted. That is intentional — reordering on read would add a Map
|
|
239
|
+
// delete+set to the hot lookup path for no security benefit.
|
|
240
|
+
if (cache.has(key))
|
|
241
|
+
cache.delete(key);
|
|
228
242
|
cache.set(key, { verified, expiresMs: now + cacheTtlMs });
|
|
229
243
|
if (cache.size > cacheMax) {
|
|
230
244
|
for (const [k, v] of cache)
|
|
231
245
|
if (v.expiresMs <= now)
|
|
232
246
|
cache.delete(k);
|
|
233
247
|
}
|
|
248
|
+
// Still over the cap after pruning expired entries: evict the
|
|
249
|
+
// oldest-written live keys (front of insertion order) until within cacheMax.
|
|
250
|
+
while (cache.size > cacheMax) {
|
|
251
|
+
const oldest = cache.keys().next().value;
|
|
252
|
+
if (oldest === undefined)
|
|
253
|
+
break;
|
|
254
|
+
cache.delete(oldest);
|
|
255
|
+
}
|
|
234
256
|
};
|
|
235
257
|
const reject = (event) => {
|
|
236
258
|
opts.onBlock?.(event);
|
|
@@ -252,7 +274,12 @@ export function botGuard(opts = {}) {
|
|
|
252
274
|
reject({ reason: "blocked-user-agent", userAgent: ua });
|
|
253
275
|
return undefined;
|
|
254
276
|
}
|
|
255
|
-
const rule = verifiedBots.find((r) =>
|
|
277
|
+
const rule = verifiedBots.find((r) => {
|
|
278
|
+
r.userAgent.lastIndex = 0;
|
|
279
|
+
const hit = r.userAgent.test(ua);
|
|
280
|
+
r.userAgent.lastIndex = 0;
|
|
281
|
+
return hit;
|
|
282
|
+
});
|
|
256
283
|
if (!rule)
|
|
257
284
|
return undefined;
|
|
258
285
|
const ip = resolveIp(ctx);
|