@zudojs/http 1.1.0 → 1.3.0
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 +53 -3
- package/dist/httpAdapter/http.adapter.d.ts +34 -3
- package/dist/httpAdapter/http.adapter.js +22 -6
- package/dist/httpAdapter/http.adapters.d.ts +24 -3
- package/dist/httpAdapter/http.adapters.js +24 -45
- package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +2 -2
- package/dist/httpAdapter/httpAdapter.errorResponse.js +26 -9
- package/dist/httpAdapter/httpAdapter.logger.d.ts +19 -0
- package/dist/httpAdapter/httpAdapter.logger.js +21 -0
- package/dist/httpAdapter/node/httpNode.adapter.d.ts +12 -1
- package/dist/httpAdapter/node/httpNode.adapter.js +28 -14
- package/dist/httpAdapter/node/httpNode.request.d.ts +8 -7
- package/dist/httpAdapter/node/httpNode.request.js +17 -27
- package/dist/httpAdapter/node/httpNode.server.d.ts +6 -0
- package/dist/httpAdapter/node/httpNode.server.js +6 -0
- package/dist/httpAdapter/node/httpNode.type.d.ts +2 -1
- package/dist/httpAgent/http.agent.d.ts +22 -3
- package/dist/httpAgent/http.agent.js +52 -14
- package/dist/httpCacheControl/httpCacheControl.freshness.d.ts +7 -1
- package/dist/httpCacheControl/httpCacheControl.freshness.js +30 -3
- package/dist/httpCookies/http.cookies.d.ts +29 -2
- package/dist/httpCookies/http.cookies.js +44 -24
- package/dist/httpCookies/httpCookies.defaults.d.ts +40 -0
- package/dist/httpCookies/httpCookies.defaults.js +42 -0
- package/dist/httpCookies/index.d.ts +1 -0
- package/dist/httpCookies/index.js +1 -0
- package/dist/httpKeepAlive/httpKeepAlive.core.js +14 -2
- package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.d.ts +22 -1
- package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.js +19 -4
- package/dist/httpMiddleware/builtin/conditional/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.d.ts +8 -0
- package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.js +25 -22
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.accessor.js +2 -6
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.d.ts +33 -0
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.js +59 -0
- package/dist/httpMiddleware/builtin/helpers/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/helpers/index.js +1 -0
- package/dist/httpMiddleware/builtin/image/httpMiddleware.image.d.ts +13 -0
- package/dist/httpMiddleware/builtin/image/httpMiddleware.image.js +31 -53
- package/dist/httpMiddleware/builtin/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/index.js +1 -0
- package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.d.ts +18 -0
- package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.js +17 -1
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +47 -0
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +61 -0
- package/dist/httpMiddleware/builtin/rateLimit/index.d.ts +7 -0
- package/dist/httpMiddleware/builtin/rateLimit/index.js +7 -0
- package/dist/httpMiddleware/builtin/security/httpMiddleware.security.d.ts +11 -0
- package/dist/httpMiddleware/builtin/security/httpMiddleware.security.js +25 -12
- package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +18 -4
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.d.ts +19 -1
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.js +38 -59
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.d.ts +12 -0
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.js +31 -0
- package/dist/httpMiddleware/httpMiddleware.error.d.ts +5 -31
- package/dist/httpMiddleware/httpMiddleware.error.js +5 -53
- package/dist/httpNegotiation/httpNegotiation.core.d.ts +13 -0
- package/dist/httpNegotiation/httpNegotiation.core.js +60 -8
- package/dist/httpProxy/http.proxy.d.ts +16 -0
- package/dist/httpProxy/http.proxy.js +44 -3
- package/dist/httpProxy/httpProxy.pathGuard.d.ts +23 -0
- package/dist/httpProxy/httpProxy.pathGuard.js +43 -0
- package/dist/httpProxy/index.d.ts +1 -0
- package/dist/httpProxy/index.js +1 -0
- package/dist/httpQuery/index.d.ts +11 -2
- package/dist/httpQuery/index.js +11 -2
- package/dist/httpQuery/queryParse/index.d.ts +10 -0
- package/dist/httpQuery/queryParse/index.js +10 -0
- package/dist/httpQuery/queryParse/queryParse.flat.d.ts +13 -0
- package/dist/httpQuery/queryParse/queryParse.flat.js +44 -0
- package/dist/httpQuery/queryParse/queryParse.nested.d.ts +25 -0
- package/dist/httpQuery/queryParse/queryParse.nested.js +112 -0
- package/dist/httpQuery/queryParse/queryParse.tokenizer.d.ts +37 -0
- package/dist/httpQuery/queryParse/queryParse.tokenizer.js +95 -0
- package/dist/httpQuery/queryRequest/index.d.ts +9 -0
- package/dist/httpQuery/queryRequest/index.js +9 -0
- package/dist/httpQuery/queryRequest/query.request.d.ts +43 -0
- package/dist/httpQuery/queryRequest/query.request.js +96 -0
- package/dist/httpQuery/querySerialize/index.d.ts +10 -0
- package/dist/httpQuery/querySerialize/index.js +10 -0
- package/dist/httpQuery/querySerialize/query.util.d.ts +21 -0
- package/dist/httpQuery/querySerialize/query.util.js +67 -0
- package/dist/httpQuery/querySerialize/querySerialize.core.d.ts +12 -0
- package/dist/httpQuery/querySerialize/querySerialize.core.js +97 -0
- package/dist/httpQuery/queryTypes/index.d.ts +11 -0
- package/dist/httpQuery/queryTypes/index.js +9 -0
- package/dist/httpQuery/queryTypes/query.container.d.ts +16 -0
- package/dist/httpQuery/queryTypes/query.container.js +51 -0
- package/dist/httpQuery/queryTypes/query.limit.d.ts +25 -0
- package/dist/httpQuery/queryTypes/query.limit.js +32 -0
- package/dist/httpQuery/queryTypes/query.type.d.ts +62 -0
- package/dist/httpQuery/queryTypes/query.type.js +2 -0
- package/dist/httpRedirect/http.redirect.d.ts +6 -0
- package/dist/httpRedirect/http.redirect.js +53 -2
- package/dist/httpRequest/http.request.d.ts +61 -2
- package/dist/httpRequest/http.request.js +86 -35
- package/dist/httpRequest/httpRequest.context.d.ts +7 -0
- package/dist/httpRequest/httpRequest.context.js +26 -34
- package/dist/httpRequest/index.d.ts +1 -0
- package/dist/httpRequest/index.js +1 -0
- package/dist/httpRequest/target/httpRequest.target.d.ts +45 -0
- package/dist/httpRequest/target/httpRequest.target.js +111 -0
- package/dist/httpRequest/target/index.d.ts +9 -0
- package/dist/httpRequest/target/index.js +9 -0
- package/dist/httpResponse/httpResponse.helper.js +2 -1
- package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +18 -1
- package/dist/httpRouter/core/factory/httpRoute.factory.base.js +49 -6
- package/dist/httpRouter/core/factory/httpRoute.factory.js +3 -3
- package/dist/httpRouter/core/register/httpRouter.register.js +16 -21
- package/dist/httpRouter/core/types/httpRouter.type.d.ts +6 -0
- package/dist/httpRouter/core/util/httpRoute.util.d.ts +61 -0
- package/dist/httpRouter/core/util/httpRoute.util.js +102 -17
- package/dist/httpRouter/dispatch/httpRoute.dispatcher.d.ts +11 -0
- package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +5 -3
- package/dist/httpRouter/matching/httpRoute.matcher.core.js +51 -6
- package/dist/httpRouter/matching/httpRoute.matcher.d.ts +0 -1
- package/dist/httpRouter/matching/httpRoute.matcher.js +64 -26
- package/dist/httpRouter/pattern/httpRoute.pattern.parse.d.ts +16 -0
- package/dist/httpRouter/pattern/httpRoute.pattern.parse.js +40 -11
- package/dist/httpRouter/pattern/index.d.ts +1 -1
- package/dist/httpRouter/pattern/index.js +1 -1
- package/dist/httpSecurity/httpSecurity.config.d.ts +6 -0
- package/dist/httpSecurity/httpSecurity.config.js +1 -0
- package/dist/httpSecurity/httpSecurity.guard.d.ts +7 -9
- package/dist/httpSecurity/httpSecurity.guard.js +7 -14
- package/dist/httpSecurity/httpSecurity.nodeGuard.d.ts +48 -0
- package/dist/httpSecurity/httpSecurity.nodeGuard.js +32 -0
- package/dist/httpSecurity/httpSecurity.validator.js +20 -9
- package/dist/httpSecurity/index.d.ts +2 -0
- package/dist/httpSecurity/index.js +1 -0
- package/dist/httpServer/core/httpServer.core.d.ts +11 -0
- package/dist/httpServer/core/httpServer.core.js +30 -4
- package/dist/httpTrustProxy/httpTrustProxy.compilation.d.ts +5 -2
- package/dist/httpTrustProxy/httpTrustProxy.compilation.js +19 -2
- package/dist/httpTrustProxy/httpTrustProxy.type.d.ts +3 -1
- package/dist/index.js +0 -1
- package/package.json +5 -5
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { HTTP_HEADERS } from "../httpConstants/http.constants.js";
|
|
2
2
|
import { InvalidJSONError } from "../httpErrors/httpError.helper.js";
|
|
3
|
+
import { parseQueryString as parseHardenedQueryString } from "../httpQuery/queryParse/index.js";
|
|
4
|
+
import { getClientIp, isTrustedPeer, } from "../httpTrustProxy/httpTrustProxy.helper.js";
|
|
3
5
|
/* -------------------------------------------------------------------------- */
|
|
4
6
|
/* Request Headers */
|
|
5
7
|
/* -------------------------------------------------------------------------- */
|
|
@@ -72,7 +74,8 @@ export class NodeHTTPRequest {
|
|
|
72
74
|
cachedJSON;
|
|
73
75
|
jsonParsed = false;
|
|
74
76
|
constructor(request, options = {}) {
|
|
75
|
-
const
|
|
77
|
+
const trustProxy = options.trustProxy ?? false;
|
|
78
|
+
const protocol = getRequestProtocol(request, trustProxy);
|
|
76
79
|
const host = getRequestHost(request);
|
|
77
80
|
const path = getRequestPath(request);
|
|
78
81
|
this.method = normalizeHTTPMethod(request.method);
|
|
@@ -84,9 +87,9 @@ export class NodeHTTPRequest {
|
|
|
84
87
|
this.params = options.params ?? {};
|
|
85
88
|
this.protocol = protocol;
|
|
86
89
|
this.hostname = getHostname(host);
|
|
87
|
-
this.ip = options.ip ?? getRequestIP(request);
|
|
90
|
+
this.ip = options.ip ?? getRequestIP(request, trustProxy);
|
|
88
91
|
this.ips = options.ips;
|
|
89
|
-
this.secure = protocol === "https"
|
|
92
|
+
this.secure = protocol === "https";
|
|
90
93
|
this.rawBody = options.rawBody;
|
|
91
94
|
this.body = options.body;
|
|
92
95
|
this.signal = options.signal;
|
|
@@ -229,10 +232,41 @@ export function getHostname(host) {
|
|
|
229
232
|
/* -------------------------------------------------------------------------- */
|
|
230
233
|
/* Protocol */
|
|
231
234
|
/* -------------------------------------------------------------------------- */
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
/** The only schemes a forwarded proto may name. */
|
|
236
|
+
const FORWARDED_PROTOCOLS = Object.freeze(["http", "https"]);
|
|
237
|
+
/**
|
|
238
|
+
* Presents an `IncomingMessage` in the shape `httpTrustProxy` works on, so
|
|
239
|
+
* this path and the Node adapter share one implementation of the hop logic.
|
|
240
|
+
*/
|
|
241
|
+
function toProxyRequest(request) {
|
|
242
|
+
return {
|
|
243
|
+
headers: request.headers,
|
|
244
|
+
socket: { remoteAddress: request.socket?.remoteAddress },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Resolves the scheme the client used.
|
|
249
|
+
*
|
|
250
|
+
* `X-Forwarded-Proto` is written by whoever opened the socket, so it is read
|
|
251
|
+
* only when that peer is a configured trusted proxy, and only when it names
|
|
252
|
+
* `http` or `https` — a value such as `wss` is discarded rather than
|
|
253
|
+
* propagated. With the default `trustProxy` of `false` the socket's own TLS
|
|
254
|
+
* state is the only input.
|
|
255
|
+
*
|
|
256
|
+
* @param request - The incoming Node request.
|
|
257
|
+
* @param trustProxy - Which peers may speak through `X-Forwarded-Proto`.
|
|
258
|
+
* @returns `"https"` or `"http"`.
|
|
259
|
+
*/
|
|
260
|
+
export function getRequestProtocol(request, trustProxy = false) {
|
|
261
|
+
if (isTrustedPeer(toProxyRequest(request), trustProxy)) {
|
|
262
|
+
const forwarded = request.headers[HTTP_HEADERS.X_FORWARDED_PROTO];
|
|
263
|
+
const value = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
264
|
+
if (typeof value === "string") {
|
|
265
|
+
const proto = (value.split(",", 1)[0] ?? "").trim().toLowerCase();
|
|
266
|
+
if (FORWARDED_PROTOCOLS.includes(proto)) {
|
|
267
|
+
return proto;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
236
270
|
}
|
|
237
271
|
if ("encrypted" in request.socket &&
|
|
238
272
|
request.socket.encrypted) {
|
|
@@ -243,43 +277,60 @@ export function getRequestProtocol(request) {
|
|
|
243
277
|
/* -------------------------------------------------------------------------- */
|
|
244
278
|
/* IP */
|
|
245
279
|
/* -------------------------------------------------------------------------- */
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Resolves the client address.
|
|
282
|
+
*
|
|
283
|
+
* The socket peer is authoritative. `X-Forwarded-For` is consulted only when
|
|
284
|
+
* that peer is a configured trusted proxy, and the chain walk is delegated to
|
|
285
|
+
* `getClientIp` so there is a single implementation of the hop logic. With
|
|
286
|
+
* the default `trustProxy` of `false` the peer address is returned unchanged,
|
|
287
|
+
* which is what an allowlist, per-IP rate limit or audit trail keyed on
|
|
288
|
+
* `req.ip` needs.
|
|
289
|
+
*
|
|
290
|
+
* @param request - The incoming Node request.
|
|
291
|
+
* @param trustProxy - Which peers may speak through `X-Forwarded-For`.
|
|
292
|
+
* @returns The client address, or `undefined` when the socket has none.
|
|
293
|
+
*/
|
|
294
|
+
export function getRequestIP(request, trustProxy = false) {
|
|
295
|
+
const proxyRequest = toProxyRequest(request);
|
|
296
|
+
const peer = proxyRequest.socket?.remoteAddress;
|
|
297
|
+
if (!isTrustedPeer(proxyRequest, trustProxy)) {
|
|
298
|
+
return peer ?? undefined;
|
|
253
299
|
}
|
|
254
|
-
return
|
|
300
|
+
return getClientIp(proxyRequest, trustProxy) ?? peer ?? undefined;
|
|
255
301
|
}
|
|
256
302
|
/* -------------------------------------------------------------------------- */
|
|
257
303
|
/* Query */
|
|
258
304
|
/* -------------------------------------------------------------------------- */
|
|
305
|
+
/**
|
|
306
|
+
* Parses the query component of a request-target into a flat record.
|
|
307
|
+
*
|
|
308
|
+
* Delegates to the hardened `httpQuery` parser, the same one behind the Node
|
|
309
|
+
* adapter's `request.query` and the router's `ctx.query`. This function used
|
|
310
|
+
* to carry its own loop that accumulated into an object literal and read
|
|
311
|
+
* `result[key]` without an own-property check, which had two consequences on
|
|
312
|
+
* fully attacker-controlled input:
|
|
313
|
+
*
|
|
314
|
+
* - `?__proto__=a&__proto__=b` assigned an array through the `__proto__`
|
|
315
|
+
* setter, so the returned query object's prototype became that array. The
|
|
316
|
+
* parameter vanished from its own keys while the object silently gained
|
|
317
|
+
* `length`, `map` and the rest of `Array.prototype`.
|
|
318
|
+
* - `?constructor=x` read the inherited `Object` constructor as the "existing"
|
|
319
|
+
* value and stored it in the result, handing a handler
|
|
320
|
+
* `query.constructor === [Function: Object], "x"]`.
|
|
321
|
+
*
|
|
322
|
+
* It also applied none of the four documented query limits, so a request with
|
|
323
|
+
* 50,000 parameters was parsed in full. Delegating fixes all three, and makes
|
|
324
|
+
* a limit breach throw {@link HTTPQueryLimitError} (414) as it already did on
|
|
325
|
+
* every other request path.
|
|
326
|
+
*/
|
|
259
327
|
export function parseQueryString(url) {
|
|
260
328
|
const queryIndex = url.indexOf("?");
|
|
261
329
|
if (queryIndex < 0) {
|
|
262
|
-
return
|
|
263
|
-
}
|
|
264
|
-
const queryString = url.slice(queryIndex + 1);
|
|
265
|
-
if (!queryString) {
|
|
266
|
-
return {};
|
|
267
|
-
}
|
|
268
|
-
const searchParams = new URLSearchParams(queryString);
|
|
269
|
-
const result = {};
|
|
270
|
-
for (const [key, value] of searchParams.entries()) {
|
|
271
|
-
const existing = result[key];
|
|
272
|
-
if (existing === undefined) {
|
|
273
|
-
result[key] = value;
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (Array.isArray(existing)) {
|
|
277
|
-
result[key] = [...existing, value];
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
result[key] = [existing, value];
|
|
330
|
+
return parseHardenedQueryString(undefined);
|
|
281
331
|
}
|
|
282
|
-
|
|
332
|
+
const hashIndex = url.indexOf("#", queryIndex + 1);
|
|
333
|
+
return parseHardenedQueryString(url.slice(queryIndex + 1, hashIndex === -1 ? undefined : hashIndex));
|
|
283
334
|
}
|
|
284
335
|
/* -------------------------------------------------------------------------- */
|
|
285
336
|
/* Method */
|
|
@@ -117,6 +117,13 @@ export declare function runWithRequestContext<T>(context: HttpRequestContext, ca
|
|
|
117
117
|
export declare function getCurrentRequestContext(): HttpRequestContext | undefined;
|
|
118
118
|
export declare function assertRequestContext(context: HttpRequestContext | undefined | null): asserts context is HttpRequestContext;
|
|
119
119
|
export declare function isRequestContext(value: unknown): value is HttpRequestContext;
|
|
120
|
+
/**
|
|
121
|
+
* Returns the canonical path of a request-target. An origin-form target such
|
|
122
|
+
* as `//host/admin` stays a path; it is never parsed as an authority.
|
|
123
|
+
*/
|
|
120
124
|
export declare function getPathname(url: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Returns the search parameters of a request-target.
|
|
127
|
+
*/
|
|
121
128
|
export declare function getSearchParams(url: string): URLSearchParams;
|
|
122
129
|
//# sourceMappingURL=httpRequest.context.d.ts.map
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* The context is intentionally framework-agnostic so adapters can populate it
|
|
8
8
|
* from Node.js, Bun, Deno, or another HTTP runtime.
|
|
9
9
|
*/
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
|
+
import { parseRequestTarget } from "./target/httpRequest.target.js";
|
|
10
12
|
/* -------------------------------------------------------------------------- */
|
|
11
13
|
/* Constants */
|
|
12
14
|
/* -------------------------------------------------------------------------- */
|
|
@@ -98,7 +100,11 @@ export class HttpRequestContext {
|
|
|
98
100
|
/* Query */
|
|
99
101
|
/* ------------------------------------------------------------------------ */
|
|
100
102
|
get query() {
|
|
101
|
-
|
|
103
|
+
const query = Object.create(null);
|
|
104
|
+
for (const [name, value] of this.queryMap) {
|
|
105
|
+
query[name] = value;
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze(query);
|
|
102
108
|
}
|
|
103
109
|
hasQuery(name) {
|
|
104
110
|
return this.queryMap.has(name);
|
|
@@ -326,24 +332,18 @@ export function isRequestContext(value) {
|
|
|
326
332
|
/* -------------------------------------------------------------------------- */
|
|
327
333
|
/* URL Helpers */
|
|
328
334
|
/* -------------------------------------------------------------------------- */
|
|
335
|
+
/**
|
|
336
|
+
* Returns the canonical path of a request-target. An origin-form target such
|
|
337
|
+
* as `//host/admin` stays a path; it is never parsed as an authority.
|
|
338
|
+
*/
|
|
329
339
|
export function getPathname(url) {
|
|
330
|
-
|
|
331
|
-
const parsed = new URL(url, "http://zudojs.invalid");
|
|
332
|
-
return parsed.pathname || "/";
|
|
333
|
-
}
|
|
334
|
-
catch {
|
|
335
|
-
const pathname = url.split("?", 1)[0];
|
|
336
|
-
return pathname || "/";
|
|
337
|
-
}
|
|
340
|
+
return parseRequestTarget(url).pathname || "/";
|
|
338
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Returns the search parameters of a request-target.
|
|
344
|
+
*/
|
|
339
345
|
export function getSearchParams(url) {
|
|
340
|
-
|
|
341
|
-
return new URL(url, "http://zudojs.invalid").searchParams;
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
345
|
-
return new URLSearchParams(query);
|
|
346
|
-
}
|
|
346
|
+
return parseRequestTarget(url).searchParams;
|
|
347
347
|
}
|
|
348
348
|
/* -------------------------------------------------------------------------- */
|
|
349
349
|
/* Internal Helpers */
|
|
@@ -379,24 +379,16 @@ function validateHeaderValue(value) {
|
|
|
379
379
|
throw new TypeError("HTTP header value cannot contain CR or LF characters.");
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Builds the request-context store.
|
|
384
|
+
*
|
|
385
|
+
* `AsyncLocalStorage` used to be reached through `globalThis.require`, which
|
|
386
|
+
* does not exist in ESM under Node. The `typeof` guard turned that into a
|
|
387
|
+
* silent `undefined`, so `runWithRequestContext` merely called its callback
|
|
388
|
+
* and `getCurrentRequestContext` always returned `undefined`. It is imported
|
|
389
|
+
* statically now, as the rest of the package imports its Node built-ins.
|
|
390
|
+
*/
|
|
382
391
|
function createAsyncContextStorage() {
|
|
383
|
-
|
|
384
|
-
* AsyncLocalStorage is intentionally loaded lazily so the HTTP package
|
|
385
|
-
* remains usable in browser and non-Node runtimes.
|
|
386
|
-
*/
|
|
387
|
-
try {
|
|
388
|
-
const runtimeRequire = globalThis.require;
|
|
389
|
-
if (typeof runtimeRequire !== "function") {
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
const asyncHooks = runtimeRequire("node:async_hooks");
|
|
393
|
-
if (!asyncHooks.AsyncLocalStorage) {
|
|
394
|
-
return undefined;
|
|
395
|
-
}
|
|
396
|
-
return new asyncHooks.AsyncLocalStorage();
|
|
397
|
-
}
|
|
398
|
-
catch {
|
|
399
|
-
return undefined;
|
|
400
|
-
}
|
|
392
|
+
return new AsyncLocalStorage();
|
|
401
393
|
}
|
|
402
394
|
//# sourceMappingURL=httpRequest.context.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical request-target parsing.
|
|
3
|
+
*
|
|
4
|
+
* Every place that derives a path or query from `request.url` (the request
|
|
5
|
+
* context, the router, path-scoped middleware, static files) parses it here,
|
|
6
|
+
* so routing and middleware can never disagree about which path a request
|
|
7
|
+
* addresses.
|
|
8
|
+
*
|
|
9
|
+
* `new URL(target, base)` is the wrong tool for an origin-form target:
|
|
10
|
+
* `//evil/admin` is read as a scheme-relative URL with authority `evil`, so
|
|
11
|
+
* the path silently became `/admin` while `request.url` still said
|
|
12
|
+
* `//evil/admin`. Prefixing the base as a string keeps an origin-form target
|
|
13
|
+
* a path.
|
|
14
|
+
*
|
|
15
|
+
* @module httpRequest/target
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Parses a request-target (origin-form, absolute-form or `*`) into a URL.
|
|
19
|
+
*
|
|
20
|
+
* An origin-form target is never parsed as an authority, and repeated slashes
|
|
21
|
+
* in the path are collapsed. Unparseable input yields the root URL.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseRequestTarget(target: string): URL;
|
|
24
|
+
/**
|
|
25
|
+
* Returns the canonical path of a request-target.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getCanonicalPath(target: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Explains why a request-target is refused, or returns `undefined` when it is
|
|
30
|
+
* acceptable.
|
|
31
|
+
*
|
|
32
|
+
* Refused: anything that is not origin-form, absolute-form (`http(s)://`) or
|
|
33
|
+
* the asterisk-form `*`; a backslash (WHATWG URL parsing treats it as a
|
|
34
|
+
* separator); and any `.` / `..` segment, plain or percent-encoded
|
|
35
|
+
* (`%2e%2e`). Those segments are resolved away by URL parsing, so a front
|
|
36
|
+
* proxy matching `/admin*` on the raw target and the router dispatching on
|
|
37
|
+
* the resolved path would otherwise disagree about the same request.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findRequestTargetViolation(target: string): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a request-target is acceptable (see
|
|
42
|
+
* {@link findRequestTargetViolation}).
|
|
43
|
+
*/
|
|
44
|
+
export declare function isCanonicalRequestTarget(target: string): boolean;
|
|
45
|
+
//# sourceMappingURL=httpRequest.target.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical request-target parsing.
|
|
3
|
+
*
|
|
4
|
+
* Every place that derives a path or query from `request.url` (the request
|
|
5
|
+
* context, the router, path-scoped middleware, static files) parses it here,
|
|
6
|
+
* so routing and middleware can never disagree about which path a request
|
|
7
|
+
* addresses.
|
|
8
|
+
*
|
|
9
|
+
* `new URL(target, base)` is the wrong tool for an origin-form target:
|
|
10
|
+
* `//evil/admin` is read as a scheme-relative URL with authority `evil`, so
|
|
11
|
+
* the path silently became `/admin` while `request.url` still said
|
|
12
|
+
* `//evil/admin`. Prefixing the base as a string keeps an origin-form target
|
|
13
|
+
* a path.
|
|
14
|
+
*
|
|
15
|
+
* @module httpRequest/target
|
|
16
|
+
*/
|
|
17
|
+
const TARGET_BASE = "http://zudojs.invalid";
|
|
18
|
+
const ABSOLUTE_FORM = /^https?:\/\//i;
|
|
19
|
+
const ENCODED_DOT = /%2e/gi;
|
|
20
|
+
const REPEATED_SLASH = /\/{2,}/g;
|
|
21
|
+
/**
|
|
22
|
+
* Collapses repeated slashes in a parsed target's path.
|
|
23
|
+
*
|
|
24
|
+
* The router normalises `/{2,}` away before matching, while the request
|
|
25
|
+
* context kept them, so `//admin/secret` dispatched to the route registered
|
|
26
|
+
* at `/admin/secret` while a guard reading `request.path` saw a path that did
|
|
27
|
+
* not start with `/admin/`. Both sides parse here, so collapsing once here
|
|
28
|
+
* keeps them in agreement. The query and fragment are untouched.
|
|
29
|
+
*/
|
|
30
|
+
function collapsePathSlashes(url) {
|
|
31
|
+
if (!url.pathname.includes("//")) {
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
const collapsed = new URL(url.href);
|
|
35
|
+
collapsed.pathname = url.pathname.replace(REPEATED_SLASH, "/");
|
|
36
|
+
return collapsed;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parses a request-target (origin-form, absolute-form or `*`) into a URL.
|
|
40
|
+
*
|
|
41
|
+
* An origin-form target is never parsed as an authority, and repeated slashes
|
|
42
|
+
* in the path are collapsed. Unparseable input yields the root URL.
|
|
43
|
+
*/
|
|
44
|
+
export function parseRequestTarget(target) {
|
|
45
|
+
try {
|
|
46
|
+
if (target.startsWith("/")) {
|
|
47
|
+
return collapsePathSlashes(new URL(`${TARGET_BASE}${target}`));
|
|
48
|
+
}
|
|
49
|
+
if (ABSOLUTE_FORM.test(target)) {
|
|
50
|
+
return collapsePathSlashes(new URL(target));
|
|
51
|
+
}
|
|
52
|
+
return collapsePathSlashes(new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return new URL(`${TARGET_BASE}/`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Returns the canonical path of a request-target.
|
|
60
|
+
*/
|
|
61
|
+
export function getCanonicalPath(target) {
|
|
62
|
+
return parseRequestTarget(target).pathname || "/";
|
|
63
|
+
}
|
|
64
|
+
function rawPathOf(target) {
|
|
65
|
+
let path = target;
|
|
66
|
+
if (ABSOLUTE_FORM.test(path)) {
|
|
67
|
+
const afterScheme = path.indexOf("//") + 2;
|
|
68
|
+
const slash = path.indexOf("/", afterScheme);
|
|
69
|
+
path = slash === -1 ? "/" : path.slice(slash);
|
|
70
|
+
}
|
|
71
|
+
const end = path.search(/[?#]/);
|
|
72
|
+
return end === -1 ? path : path.slice(0, end);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Explains why a request-target is refused, or returns `undefined` when it is
|
|
76
|
+
* acceptable.
|
|
77
|
+
*
|
|
78
|
+
* Refused: anything that is not origin-form, absolute-form (`http(s)://`) or
|
|
79
|
+
* the asterisk-form `*`; a backslash (WHATWG URL parsing treats it as a
|
|
80
|
+
* separator); and any `.` / `..` segment, plain or percent-encoded
|
|
81
|
+
* (`%2e%2e`). Those segments are resolved away by URL parsing, so a front
|
|
82
|
+
* proxy matching `/admin*` on the raw target and the router dispatching on
|
|
83
|
+
* the resolved path would otherwise disagree about the same request.
|
|
84
|
+
*/
|
|
85
|
+
export function findRequestTargetViolation(target) {
|
|
86
|
+
if (target === "*") {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
if (!target.startsWith("/") && !ABSOLUTE_FORM.test(target)) {
|
|
90
|
+
return "Request target is not in origin-form or absolute-form.";
|
|
91
|
+
}
|
|
92
|
+
const path = rawPathOf(target);
|
|
93
|
+
if (path.includes("\\")) {
|
|
94
|
+
return "Request target contains a backslash.";
|
|
95
|
+
}
|
|
96
|
+
for (const segment of path.split("/")) {
|
|
97
|
+
const decoded = segment.replace(ENCODED_DOT, ".");
|
|
98
|
+
if (decoded === "." || decoded === "..") {
|
|
99
|
+
return "Request target contains a dot segment.";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether a request-target is acceptable (see
|
|
106
|
+
* {@link findRequestTargetViolation}).
|
|
107
|
+
*/
|
|
108
|
+
export function isCanonicalRequestTarget(target) {
|
|
109
|
+
return findRequestTargetViolation(target) === undefined;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=httpRequest.target.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/http/httpRequest/target
|
|
3
|
+
*
|
|
4
|
+
* Canonical request-target parsing shared by the request context, router and
|
|
5
|
+
* path-scoped middleware, plus the dot-segment / non-origin-form check the
|
|
6
|
+
* Node adapter applies before a request is dispatched.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./httpRequest.target.js";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/http/httpRequest/target
|
|
3
|
+
*
|
|
4
|
+
* Canonical request-target parsing shared by the request context, router and
|
|
5
|
+
* path-scoped middleware, plus the dot-segment / non-origin-form check the
|
|
6
|
+
* Node adapter applies before a request is dispatched.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./httpRequest.target.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module httpResponse/helpers
|
|
5
5
|
*/
|
|
6
|
+
import { withSecureCookieDefaults } from "../httpCookies/httpCookies.defaults.js";
|
|
6
7
|
import { assertSafeRedirect } from "../httpRedirect/http.redirect.js";
|
|
7
8
|
/**
|
|
8
9
|
* Creates a JSON response.
|
|
@@ -110,7 +111,7 @@ export function serializeResponseCookie(cookie) {
|
|
|
110
111
|
if (!name || !COOKIE_NAME_TOKEN.test(name)) {
|
|
111
112
|
throw new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`);
|
|
112
113
|
}
|
|
113
|
-
const opts = cookie.options
|
|
114
|
+
const opts = withSecureCookieDefaults(cookie.options, "Lax");
|
|
114
115
|
if (name.startsWith("__Host-")) {
|
|
115
116
|
if (!opts.secure) {
|
|
116
117
|
throw new TypeError("A __Host- cookie requires the Secure attribute.");
|
|
@@ -9,7 +9,18 @@ import { type HttpResponseContext as ResponseContext } from "../../../httpRespon
|
|
|
9
9
|
export declare function normalizeMethod(method: string): HttpMethod | "*";
|
|
10
10
|
export declare function normalizeMethods(method: HttpMethod | readonly HttpMethod[] | "*"): readonly (HttpMethod | "*")[];
|
|
11
11
|
export declare function isHttpMethod(value: string): value is HttpMethod;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Collects the methods registered for a path.
|
|
14
|
+
*
|
|
15
|
+
* @param routes - The compiled routes to consider.
|
|
16
|
+
* @param path - The request path.
|
|
17
|
+
* @param caseSensitive - The router's case sensitivity. This used to be
|
|
18
|
+
* hardcoded to `false`, so a case-sensitive router advertised `Allow`
|
|
19
|
+
* methods belonging to a route that only differed by case — a method the
|
|
20
|
+
* client would then get a 404 from, and a disclosure of the other route.
|
|
21
|
+
* @returns The allowed methods, with `HEAD` implied by `GET`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function collectAllowedMethods(routes: readonly CompiledRoute[], path: string, caseSensitive?: boolean): HttpMethod[];
|
|
13
24
|
/**
|
|
14
25
|
* Extracts the monotonic registration sequence from a generated route id.
|
|
15
26
|
*
|
|
@@ -39,6 +50,12 @@ export declare function defaultNotFoundHandler(context: HttpRouterRequestContext
|
|
|
39
50
|
export declare function defaultMethodNotAllowedHandler(context: HttpRouterRequestContext, allowedMethods: readonly HttpMethod[]): ResponseContext;
|
|
40
51
|
/**
|
|
41
52
|
* Runs a matched route's middleware chain followed by its handler.
|
|
53
|
+
*
|
|
54
|
+
* Every result is folded into the ambient response context
|
|
55
|
+
* (`context.middleware.response`), which is the object route middleware
|
|
56
|
+
* writes to. Returning the handler's brand new response instead — as this
|
|
57
|
+
* used to — silently discarded every header, cookie and status a route
|
|
58
|
+
* middleware had set before calling `next()`.
|
|
42
59
|
*/
|
|
43
60
|
export declare function executeRoute(route: MatchedRoute, context: HttpRouterContext): Promise<ResponseContext>;
|
|
44
61
|
//# sourceMappingURL=httpRoute.factory.base.d.ts.map
|
|
@@ -36,10 +36,21 @@ export function isHttpMethod(value) {
|
|
|
36
36
|
value === "CONNECT" ||
|
|
37
37
|
value === "TRACE");
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Collects the methods registered for a path.
|
|
41
|
+
*
|
|
42
|
+
* @param routes - The compiled routes to consider.
|
|
43
|
+
* @param path - The request path.
|
|
44
|
+
* @param caseSensitive - The router's case sensitivity. This used to be
|
|
45
|
+
* hardcoded to `false`, so a case-sensitive router advertised `Allow`
|
|
46
|
+
* methods belonging to a route that only differed by case — a method the
|
|
47
|
+
* client would then get a 404 from, and a disclosure of the other route.
|
|
48
|
+
* @returns The allowed methods, with `HEAD` implied by `GET`.
|
|
49
|
+
*/
|
|
50
|
+
export function collectAllowedMethods(routes, path, caseSensitive = false) {
|
|
40
51
|
const methods = new Set();
|
|
41
52
|
for (const route of routes) {
|
|
42
|
-
if (!matchCompiledRoute(route, path,
|
|
53
|
+
if (!matchCompiledRoute(route, path, caseSensitive)) {
|
|
43
54
|
continue;
|
|
44
55
|
}
|
|
45
56
|
if (isHttpMethod(route.definition.method)) {
|
|
@@ -153,11 +164,43 @@ export function defaultMethodNotAllowedHandler(context, allowedMethods) {
|
|
|
153
164
|
/* -------------------------------------------------------------------------- */
|
|
154
165
|
/* Route Execution */
|
|
155
166
|
/* -------------------------------------------------------------------------- */
|
|
167
|
+
/**
|
|
168
|
+
* Merges one response context into another.
|
|
169
|
+
*
|
|
170
|
+
* Status, status text, headers, cookies, metadata and body are all carried
|
|
171
|
+
* over, so nothing a handler produced is lost.
|
|
172
|
+
*
|
|
173
|
+
* @param target - The response that stays authoritative.
|
|
174
|
+
* @param source - The response to fold into it.
|
|
175
|
+
* @returns The target response.
|
|
176
|
+
*/
|
|
177
|
+
function mergeRouteResponse(target, source) {
|
|
178
|
+
if (source === target) {
|
|
179
|
+
return target;
|
|
180
|
+
}
|
|
181
|
+
target.setStatus(source.status, source.statusText);
|
|
182
|
+
target.headers_obj(source.headers);
|
|
183
|
+
for (const cookie of source.cookies) {
|
|
184
|
+
target.setCookie(cookie);
|
|
185
|
+
}
|
|
186
|
+
for (const [key, value] of Object.entries(source.metadata)) {
|
|
187
|
+
target.setMetadata(key, value);
|
|
188
|
+
}
|
|
189
|
+
target.setBody(source.body);
|
|
190
|
+
return target;
|
|
191
|
+
}
|
|
156
192
|
/**
|
|
157
193
|
* Runs a matched route's middleware chain followed by its handler.
|
|
194
|
+
*
|
|
195
|
+
* Every result is folded into the ambient response context
|
|
196
|
+
* (`context.middleware.response`), which is the object route middleware
|
|
197
|
+
* writes to. Returning the handler's brand new response instead — as this
|
|
198
|
+
* used to — silently discarded every header, cookie and status a route
|
|
199
|
+
* middleware had set before calling `next()`.
|
|
158
200
|
*/
|
|
159
201
|
export async function executeRoute(route, context) {
|
|
160
202
|
const layers = route.middleware;
|
|
203
|
+
const ambient = context.middleware.response;
|
|
161
204
|
let invoked = -1;
|
|
162
205
|
const run = async (index) => {
|
|
163
206
|
if (index <= invoked) {
|
|
@@ -166,7 +209,7 @@ export async function executeRoute(route, context) {
|
|
|
166
209
|
invoked = index;
|
|
167
210
|
const layer = layers[index];
|
|
168
211
|
if (layer === undefined) {
|
|
169
|
-
return normalizeResponse(await route.handler(context));
|
|
212
|
+
return mergeRouteResponse(ambient, await normalizeResponse(await route.handler(context)));
|
|
170
213
|
}
|
|
171
214
|
let downstream;
|
|
172
215
|
const result = await layer(context.middleware, async () => {
|
|
@@ -174,12 +217,12 @@ export async function executeRoute(route, context) {
|
|
|
174
217
|
return downstream;
|
|
175
218
|
});
|
|
176
219
|
if (result instanceof HttpResponseContext) {
|
|
177
|
-
return result;
|
|
220
|
+
return mergeRouteResponse(ambient, result);
|
|
178
221
|
}
|
|
179
222
|
if (typeof Response !== "undefined" && result instanceof Response) {
|
|
180
|
-
return normalizeResponse(result);
|
|
223
|
+
return mergeRouteResponse(ambient, await normalizeResponse(result));
|
|
181
224
|
}
|
|
182
|
-
return downstream ??
|
|
225
|
+
return downstream ?? ambient;
|
|
183
226
|
};
|
|
184
227
|
return run(0);
|
|
185
228
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { HttpRouter } from "../register/httpRouter.register.js";
|
|
5
5
|
import { HttpRouterGroup } from "../group/httpRouterGroup.core.js";
|
|
6
6
|
import { RouteConflictError, InvalidRoutePatternError, } from "../error/httpRouter.error.js";
|
|
7
|
-
import {
|
|
7
|
+
import { normalizeRoutePattern } from "../util/httpRoute.util.js";
|
|
8
8
|
/**
|
|
9
9
|
* Creates a new HTTP router instance.
|
|
10
10
|
*/
|
|
@@ -17,7 +17,7 @@ export function createRouter(options = {}) {
|
|
|
17
17
|
export function createRoute(definition) {
|
|
18
18
|
return {
|
|
19
19
|
...definition,
|
|
20
|
-
path:
|
|
20
|
+
path: normalizeRoutePattern(definition.path),
|
|
21
21
|
middleware: Object.freeze([...(definition.middleware ?? [])]),
|
|
22
22
|
metadata: Object.freeze({ ...(definition.metadata ?? {}) }),
|
|
23
23
|
};
|
|
@@ -26,7 +26,7 @@ export function createRoute(definition) {
|
|
|
26
26
|
* Builds a route path from a pattern and parameters.
|
|
27
27
|
*/
|
|
28
28
|
export function buildRoutePath(pattern, params = {}) {
|
|
29
|
-
const normalized =
|
|
29
|
+
const normalized = normalizeRoutePattern(pattern);
|
|
30
30
|
return normalized
|
|
31
31
|
.replace(/:([a-zA-Z_][a-zA-Z0-9_-]*)(\?)?/g, (_match, name, optional) => {
|
|
32
32
|
const value = params[name];
|