@zudojs/http 1.0.0 → 1.2.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 +64 -6
- package/dist/httpAdapter/http.adapter.d.ts +34 -3
- package/dist/httpAdapter/http.adapter.js +28 -10
- package/dist/httpAdapter/http.adapters.js +4 -34
- package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +35 -0
- package/dist/httpAdapter/httpAdapter.errorResponse.js +137 -0
- 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 +20 -1
- package/dist/httpAdapter/node/httpNode.adapter.js +80 -20
- package/dist/httpAdapter/node/httpNode.request.d.ts +12 -1
- package/dist/httpAdapter/node/httpNode.request.js +23 -16
- 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/httpClient/httpClient.executor.js +14 -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/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/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/static/httpMiddleware.static.js +30 -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/httpProxy/http.proxy.d.ts +7 -0
- package/dist/httpProxy/http.proxy.js +9 -0
- 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/httpRequest/httpRequest.context.d.ts +7 -0
- package/dist/httpRequest/httpRequest.context.js +15 -16
- 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 +93 -0
- package/dist/httpRequest/target/index.d.ts +9 -0
- package/dist/httpRequest/target/index.js +9 -0
- package/dist/httpResponse/httpResponse.context.d.ts +10 -0
- package/dist/httpResponse/httpResponse.context.js +13 -1
- package/dist/httpResponse/httpResponse.helper.d.ts +22 -0
- package/dist/httpResponse/httpResponse.helper.js +94 -24
- package/dist/httpRouter/core/registry/core/httpRegistry.helper.js +1 -1
- package/dist/httpRouter/core/util/httpRoute.util.d.ts +14 -0
- package/dist/httpRouter/core/util/httpRoute.util.js +18 -14
- package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +12 -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 +4 -2
- 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 +9 -5
|
@@ -3,79 +3,58 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module httpMiddleware/builtin/video
|
|
5
5
|
*
|
|
6
|
-
* Requires: npm install fluent-ffmpeg
|
|
6
|
+
* Requires: npm install fluent-ffmpeg (and an ffmpeg binary)
|
|
7
|
+
*/
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { applyCompressedBody, getResponseBytes, getResponseMediaType, loadOptionalModule, } from "../helpers/index.js";
|
|
10
|
+
import { CONTAINER_TYPES, DEFAULT_VIDEO_FORMAT as DEFAULT_FORMAT, buildOutputOptions, } from "./httpMiddleware.video.options.js";
|
|
11
|
+
/**
|
|
12
|
+
* Transcodes a video held in memory.
|
|
13
|
+
*
|
|
14
|
+
* `fluent-ffmpeg`'s export is a factory: `input()` exists only on the
|
|
15
|
+
* command it returns. Calling it on the module (as this did) threw
|
|
16
|
+
* `ffmpegInstance.input is not a function` on every call.
|
|
7
17
|
*/
|
|
8
|
-
import { applyHeadersToResponse } from "../helpers/index.js";
|
|
9
|
-
const DEFAULT_CRF = 28;
|
|
10
|
-
const DEFAULT_PRESET = "medium";
|
|
11
|
-
const DEFAULT_FORMAT = "mp4";
|
|
12
|
-
async function getFfmpeg() {
|
|
13
|
-
try {
|
|
14
|
-
const mod = await import("fluent-ffmpeg");
|
|
15
|
-
return mod.default ?? mod;
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
throw new Error("fluent-ffmpeg is not installed. Run: npm install fluent-ffmpeg");
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
18
|
export async function compressVideo(inputBuffer, options = {}) {
|
|
22
|
-
const
|
|
23
|
-
const crf = options.crf ?? DEFAULT_CRF;
|
|
24
|
-
const preset = options.preset ?? DEFAULT_PRESET;
|
|
25
|
-
const format = options.format ?? DEFAULT_FORMAT;
|
|
19
|
+
const ffmpeg = await loadOptionalModule("fluent-ffmpeg", "npm install fluent-ffmpeg");
|
|
26
20
|
return new Promise((resolve, reject) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
.input(inputBuffer)
|
|
30
|
-
.inputFormat("mp4")
|
|
31
|
-
.outputOptions(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
resolve(Buffer.concat(outputBuffer));
|
|
39
|
-
})
|
|
40
|
-
.on("error", (err) => {
|
|
41
|
-
reject(err);
|
|
42
|
-
})
|
|
43
|
-
.pipe((err, stdout) => {
|
|
44
|
-
if (err) {
|
|
45
|
-
reject(err);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
stdout.on("data", (chunk) => {
|
|
49
|
-
outputBuffer.push(chunk);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
21
|
+
const chunks = [];
|
|
22
|
+
const output = ffmpeg()
|
|
23
|
+
.input(Readable.from([inputBuffer]))
|
|
24
|
+
.inputFormat(options.inputFormat ?? "mp4")
|
|
25
|
+
.outputOptions(buildOutputOptions(options))
|
|
26
|
+
.format(options.format ?? DEFAULT_FORMAT)
|
|
27
|
+
.on("error", reject)
|
|
28
|
+
.pipe();
|
|
29
|
+
output.on("data", (chunk) => chunks.push(chunk));
|
|
30
|
+
output.on("end", () => resolve(Buffer.concat(chunks)));
|
|
31
|
+
output.on("error", reject);
|
|
52
32
|
});
|
|
53
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Compresses `video/*` responses, reading status, headers and body from the
|
|
36
|
+
* response returned by `next()`.
|
|
37
|
+
*/
|
|
54
38
|
export function createVideoCompressionMiddleware(options = {}) {
|
|
55
39
|
const enabled = options.enabled ?? true;
|
|
56
|
-
return async (
|
|
57
|
-
if (!enabled) {
|
|
58
|
-
return next();
|
|
59
|
-
}
|
|
40
|
+
return async (_context, next) => {
|
|
60
41
|
const response = await next();
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
42
|
+
const mediaType = getResponseMediaType(response);
|
|
43
|
+
if (!enabled || !mediaType?.startsWith("video/")) {
|
|
63
44
|
return response;
|
|
64
45
|
}
|
|
65
|
-
const body =
|
|
66
|
-
if (!
|
|
46
|
+
const body = getResponseBytes(response);
|
|
47
|
+
if (!body || body.length === 0) {
|
|
67
48
|
return response;
|
|
68
49
|
}
|
|
69
|
-
const
|
|
50
|
+
const compression = options.contentTypeMap?.[mediaType] ?? {};
|
|
51
|
+
const format = compression.format ?? DEFAULT_FORMAT;
|
|
70
52
|
try {
|
|
71
|
-
const compressed = await compressVideo(body,
|
|
72
|
-
|
|
73
|
-
const headers = new Headers(response.headers);
|
|
74
|
-
headers.set("content-type", newContentType);
|
|
75
|
-
headers.set("cache-control", headers.get("cache-control") ?? "public, max-age=86400");
|
|
76
|
-
return applyHeadersToResponse(response, headers).setBody(compressed);
|
|
53
|
+
const compressed = await compressVideo(body, compression);
|
|
54
|
+
return applyCompressedBody(response, compressed, CONTAINER_TYPES[format] ?? "video/mp4");
|
|
77
55
|
}
|
|
78
|
-
catch {
|
|
56
|
+
catch (error) {
|
|
57
|
+
options.onError?.(error, response);
|
|
79
58
|
return response;
|
|
80
59
|
}
|
|
81
60
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg argument building for the video compression middleware.
|
|
3
|
+
*
|
|
4
|
+
* @module httpMiddleware/builtin/video/options
|
|
5
|
+
*/
|
|
6
|
+
import type { VideoCompressionOptions } from "./httpMiddleware.video.js";
|
|
7
|
+
export declare const DEFAULT_VIDEO_FORMAT = "mp4";
|
|
8
|
+
/** Media type per output container. */
|
|
9
|
+
export declare const CONTAINER_TYPES: Readonly<Record<string, string>>;
|
|
10
|
+
/** Builds the ffmpeg output options for a compression request. */
|
|
11
|
+
export declare function buildOutputOptions(options: VideoCompressionOptions): string[];
|
|
12
|
+
//# sourceMappingURL=httpMiddleware.video.options.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg argument building for the video compression middleware.
|
|
3
|
+
*
|
|
4
|
+
* @module httpMiddleware/builtin/video/options
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_CRF = 28;
|
|
7
|
+
const DEFAULT_PRESET = "medium";
|
|
8
|
+
export const DEFAULT_VIDEO_FORMAT = "mp4";
|
|
9
|
+
/** Media type per output container. */
|
|
10
|
+
export const CONTAINER_TYPES = Object.freeze({
|
|
11
|
+
mp4: "video/mp4",
|
|
12
|
+
webm: "video/webm",
|
|
13
|
+
mov: "video/quicktime",
|
|
14
|
+
});
|
|
15
|
+
/** Builds the ffmpeg output options for a compression request. */
|
|
16
|
+
export function buildOutputOptions(options) {
|
|
17
|
+
const output = [
|
|
18
|
+
`-crf ${options.crf ?? DEFAULT_CRF}`,
|
|
19
|
+
`-preset ${options.preset ?? DEFAULT_PRESET}`,
|
|
20
|
+
/* A pipe is not seekable, so the moov atom must be fragmented. */
|
|
21
|
+
"-movflags frag_keyframe+empty_moov",
|
|
22
|
+
];
|
|
23
|
+
if (options.bitrate) {
|
|
24
|
+
output.push(`-b:v ${options.bitrate}`);
|
|
25
|
+
}
|
|
26
|
+
if (options.scale?.width || options.scale?.height) {
|
|
27
|
+
output.push(`-vf scale=${options.scale.width ?? -2}:${options.scale.height ?? -2}`);
|
|
28
|
+
}
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=httpMiddleware.video.options.js.map
|
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP middleware error types.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `HttpMiddlewareError` and `HttpMiddlewarePipelineError` live in
|
|
5
|
+
* `@zudojs/errors` (both `BaseError`s, 500, not exposed, codes
|
|
6
|
+
* `HTTP_MIDDLEWARE_ERROR` / `MIDDLEWARE_PIPELINE_ERROR`); they are
|
|
7
|
+
* re-exported here so existing imports keep working.
|
|
6
8
|
*
|
|
7
9
|
* @module httpMiddleware/errors
|
|
8
10
|
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Error raised by an HTTP middleware function.
|
|
12
|
-
*/
|
|
13
|
-
export declare class HttpMiddlewareError extends BaseError {
|
|
14
|
-
/**
|
|
15
|
-
* The unique identifier of the middleware.
|
|
16
|
-
*/
|
|
17
|
-
readonly middlewareId: string | undefined;
|
|
18
|
-
/**
|
|
19
|
-
* The name of the middleware.
|
|
20
|
-
*/
|
|
21
|
-
readonly middlewareName: string | undefined;
|
|
22
|
-
constructor(message: string, options?: {
|
|
23
|
-
readonly middlewareId?: string;
|
|
24
|
-
readonly middlewareName?: string;
|
|
25
|
-
readonly cause?: unknown;
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Error raised when the HTTP middleware pipeline fails.
|
|
30
|
-
*/
|
|
31
|
-
export declare class HttpMiddlewarePipelineError extends BaseError {
|
|
32
|
-
/**
|
|
33
|
-
* The errors that occurred during pipeline execution.
|
|
34
|
-
*/
|
|
35
|
-
readonly errors: readonly HttpMiddlewareError[];
|
|
36
|
-
constructor(errors: readonly HttpMiddlewareError[]);
|
|
37
|
-
}
|
|
11
|
+
export { HttpMiddlewareError, HttpMiddlewarePipelineError, type HttpMiddlewareErrorOptions, } from "@zudojs/errors";
|
|
38
12
|
//# sourceMappingURL=httpMiddleware.error.d.ts.map
|
|
@@ -1,60 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP middleware error types.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `HttpMiddlewareError` and `HttpMiddlewarePipelineError` live in
|
|
5
|
+
* `@zudojs/errors` (both `BaseError`s, 500, not exposed, codes
|
|
6
|
+
* `HTTP_MIDDLEWARE_ERROR` / `MIDDLEWARE_PIPELINE_ERROR`); they are
|
|
7
|
+
* re-exported here so existing imports keep working.
|
|
6
8
|
*
|
|
7
9
|
* @module httpMiddleware/errors
|
|
8
10
|
*/
|
|
9
|
-
|
|
10
|
-
/* -------------------------------------------------------------------------- */
|
|
11
|
-
/* Errors */
|
|
12
|
-
/* -------------------------------------------------------------------------- */
|
|
13
|
-
/**
|
|
14
|
-
* Error raised by an HTTP middleware function.
|
|
15
|
-
*/
|
|
16
|
-
export class HttpMiddlewareError extends BaseError {
|
|
17
|
-
/**
|
|
18
|
-
* The unique identifier of the middleware.
|
|
19
|
-
*/
|
|
20
|
-
middlewareId;
|
|
21
|
-
/**
|
|
22
|
-
* The name of the middleware.
|
|
23
|
-
*/
|
|
24
|
-
middlewareName;
|
|
25
|
-
constructor(message, options = {}) {
|
|
26
|
-
super(message, {
|
|
27
|
-
code: "HTTP_MIDDLEWARE_ERROR",
|
|
28
|
-
statusCode: 500,
|
|
29
|
-
expose: false,
|
|
30
|
-
cause: options.cause,
|
|
31
|
-
});
|
|
32
|
-
this.name = "HttpMiddlewareError";
|
|
33
|
-
this.middlewareId = options.middlewareId;
|
|
34
|
-
this.middlewareName = options.middlewareName;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Error raised when the HTTP middleware pipeline fails.
|
|
39
|
-
*/
|
|
40
|
-
export class HttpMiddlewarePipelineError extends BaseError {
|
|
41
|
-
/**
|
|
42
|
-
* The errors that occurred during pipeline execution.
|
|
43
|
-
*/
|
|
44
|
-
errors;
|
|
45
|
-
constructor(errors) {
|
|
46
|
-
const message = errors.length === 1
|
|
47
|
-
? `HTTP middleware pipeline failed: ${errors[0]?.message ?? "Unknown error"}`
|
|
48
|
-
: `HTTP middleware pipeline failed with ${errors.length} errors: ${errors
|
|
49
|
-
.map((e) => e.message)
|
|
50
|
-
.join(", ")}`;
|
|
51
|
-
super(message, {
|
|
52
|
-
code: "MIDDLEWARE_PIPELINE_ERROR",
|
|
53
|
-
statusCode: 500,
|
|
54
|
-
expose: false,
|
|
55
|
-
});
|
|
56
|
-
this.name = "HttpMiddlewarePipelineError";
|
|
57
|
-
this.errors = Object.freeze([...errors]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
11
|
+
export { HttpMiddlewareError, HttpMiddlewarePipelineError, } from "@zudojs/errors";
|
|
60
12
|
//# sourceMappingURL=httpMiddleware.error.js.map
|
|
@@ -128,6 +128,13 @@ export declare function assertSafeProxyTarget(target: string | URL, options?: Pr
|
|
|
128
128
|
export declare function assertSafeProxyRedirect(location: string | URL, currentURL: string | URL, options?: ProxySecurityOptions): URL;
|
|
129
129
|
export declare function resolveProxyTarget(target: string | URL, options?: ProxySecurityOptions): ProxyTarget;
|
|
130
130
|
export declare function isValidProxyTarget(target: string | URL | undefined | null): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Joins a request path onto a proxy base path.
|
|
133
|
+
*
|
|
134
|
+
* @throws {HttpError} 400 when `requestPath` contains a `.` / `..` segment
|
|
135
|
+
* (plain or percent-encoded), which an upstream would resolve outside
|
|
136
|
+
* `basePath`.
|
|
137
|
+
*/
|
|
131
138
|
export declare function joinProxyPath(basePath: string, requestPath: string): string;
|
|
132
139
|
export declare function rewriteProxyPath(path: string, options?: ProxyRewriteOptions): string;
|
|
133
140
|
export declare function normalizeProxyPath(path: string): string;
|
|
@@ -11,6 +11,7 @@ import { appendHeader, deleteHeader, getHeader, setHeader, } from "../httpProtoc
|
|
|
11
11
|
import { isValidHTTPURL, isValidHeaderValue } from "../httpValidation/index.js";
|
|
12
12
|
import { isLinkLocalAddress, isLoopbackAddress, isUniqueLocalAddress, parseIpAddress, } from "../httpTrustProxy/httpTrustProxy.ip.js";
|
|
13
13
|
import { assertSafeHeaderValue } from "../httpHeaders/security/index.js";
|
|
14
|
+
import { assertProxyPathContained } from "./httpProxy.pathGuard.js";
|
|
14
15
|
/* -------------------------------------------------------------------------- */
|
|
15
16
|
/* Target */
|
|
16
17
|
/* -------------------------------------------------------------------------- */
|
|
@@ -204,7 +205,15 @@ function parseProxyURL(target) {
|
|
|
204
205
|
/* -------------------------------------------------------------------------- */
|
|
205
206
|
/* Path Handling */
|
|
206
207
|
/* -------------------------------------------------------------------------- */
|
|
208
|
+
/**
|
|
209
|
+
* Joins a request path onto a proxy base path.
|
|
210
|
+
*
|
|
211
|
+
* @throws {HttpError} 400 when `requestPath` contains a `.` / `..` segment
|
|
212
|
+
* (plain or percent-encoded), which an upstream would resolve outside
|
|
213
|
+
* `basePath`.
|
|
214
|
+
*/
|
|
207
215
|
export function joinProxyPath(basePath, requestPath) {
|
|
216
|
+
assertProxyPathContained(requestPath);
|
|
208
217
|
const base = normalizeProxyPath(basePath);
|
|
209
218
|
const request = requestPath.startsWith("/") ? requestPath : `/${requestPath}`;
|
|
210
219
|
if (base === "/") {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy request-path containment.
|
|
3
|
+
*
|
|
4
|
+
* A gateway mounts an upstream under `target.pathname` (for example
|
|
5
|
+
* `/public-api`) to expose only that subtree. The path builders used to
|
|
6
|
+
* concatenate the client's path onto the base and collapse `//` only, so
|
|
7
|
+
* `/../admin/keys` or `/%2e%2e/admin/keys` was forwarded as
|
|
8
|
+
* `/public-api/../admin/keys`, which the upstream resolves to `/admin/keys`.
|
|
9
|
+
*
|
|
10
|
+
* @module httpProxy/pathGuard
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Whether a request path contains a `.` / `..` segment once percent-encoded
|
|
14
|
+
* dots and separators (`%2e`, `%2f`, `%5c`) and backslashes are taken into
|
|
15
|
+
* account, i.e. whether an upstream might resolve it outside its base.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasProxyDotSegment(path: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Throws a `400 Bad Request` `HttpError` when the client-supplied path could
|
|
20
|
+
* escape the proxy target's base path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertProxyPathContained(path: string): void;
|
|
23
|
+
//# sourceMappingURL=httpProxy.pathGuard.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy request-path containment.
|
|
3
|
+
*
|
|
4
|
+
* A gateway mounts an upstream under `target.pathname` (for example
|
|
5
|
+
* `/public-api`) to expose only that subtree. The path builders used to
|
|
6
|
+
* concatenate the client's path onto the base and collapse `//` only, so
|
|
7
|
+
* `/../admin/keys` or `/%2e%2e/admin/keys` was forwarded as
|
|
8
|
+
* `/public-api/../admin/keys`, which the upstream resolves to `/admin/keys`.
|
|
9
|
+
*
|
|
10
|
+
* @module httpProxy/pathGuard
|
|
11
|
+
*/
|
|
12
|
+
import { badRequest } from "../httpErrors/factories/httpError.clientError.js";
|
|
13
|
+
const ENCODED_SEPARATORS = /%2e|%2f|%5c/gi;
|
|
14
|
+
function decodeSeparator(match) {
|
|
15
|
+
const code = match.toLowerCase();
|
|
16
|
+
if (code === "%2e") {
|
|
17
|
+
return ".";
|
|
18
|
+
}
|
|
19
|
+
return code === "%2f" ? "/" : "\\";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a request path contains a `.` / `..` segment once percent-encoded
|
|
23
|
+
* dots and separators (`%2e`, `%2f`, `%5c`) and backslashes are taken into
|
|
24
|
+
* account, i.e. whether an upstream might resolve it outside its base.
|
|
25
|
+
*/
|
|
26
|
+
export function hasProxyDotSegment(path) {
|
|
27
|
+
const end = path.search(/[?#]/);
|
|
28
|
+
const pathOnly = end === -1 ? path : path.slice(0, end);
|
|
29
|
+
const decoded = pathOnly.replace(ENCODED_SEPARATORS, decodeSeparator);
|
|
30
|
+
return decoded
|
|
31
|
+
.split(/[\\/]/)
|
|
32
|
+
.some((segment) => segment === "." || segment === "..");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Throws a `400 Bad Request` `HttpError` when the client-supplied path could
|
|
36
|
+
* escape the proxy target's base path.
|
|
37
|
+
*/
|
|
38
|
+
export function assertProxyPathContained(path) {
|
|
39
|
+
if (hasProxyDotSegment(path)) {
|
|
40
|
+
throw badRequest("Proxy request path contains a dot segment.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=httpProxy.pathGuard.js.map
|
package/dist/httpProxy/index.js
CHANGED
|
@@ -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,7 @@
|
|
|
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 { parseRequestTarget } from "./target/httpRequest.target.js";
|
|
10
11
|
/* -------------------------------------------------------------------------- */
|
|
11
12
|
/* Constants */
|
|
12
13
|
/* -------------------------------------------------------------------------- */
|
|
@@ -98,7 +99,11 @@ export class HttpRequestContext {
|
|
|
98
99
|
/* Query */
|
|
99
100
|
/* ------------------------------------------------------------------------ */
|
|
100
101
|
get query() {
|
|
101
|
-
|
|
102
|
+
const query = Object.create(null);
|
|
103
|
+
for (const [name, value] of this.queryMap) {
|
|
104
|
+
query[name] = value;
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze(query);
|
|
102
107
|
}
|
|
103
108
|
hasQuery(name) {
|
|
104
109
|
return this.queryMap.has(name);
|
|
@@ -326,24 +331,18 @@ export function isRequestContext(value) {
|
|
|
326
331
|
/* -------------------------------------------------------------------------- */
|
|
327
332
|
/* URL Helpers */
|
|
328
333
|
/* -------------------------------------------------------------------------- */
|
|
334
|
+
/**
|
|
335
|
+
* Returns the canonical path of a request-target. An origin-form target such
|
|
336
|
+
* as `//host/admin` stays a path; it is never parsed as an authority.
|
|
337
|
+
*/
|
|
329
338
|
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
|
-
}
|
|
339
|
+
return parseRequestTarget(url).pathname || "/";
|
|
338
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Returns the search parameters of a request-target.
|
|
343
|
+
*/
|
|
339
344
|
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
|
-
}
|
|
345
|
+
return parseRequestTarget(url).searchParams;
|
|
347
346
|
}
|
|
348
347
|
/* -------------------------------------------------------------------------- */
|
|
349
348
|
/* Internal Helpers */
|
|
@@ -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. Unparseable input
|
|
21
|
+
* 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,93 @@
|
|
|
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
|
+
/**
|
|
21
|
+
* Parses a request-target (origin-form, absolute-form or `*`) into a URL.
|
|
22
|
+
*
|
|
23
|
+
* An origin-form target is never parsed as an authority. Unparseable input
|
|
24
|
+
* yields the root URL.
|
|
25
|
+
*/
|
|
26
|
+
export function parseRequestTarget(target) {
|
|
27
|
+
try {
|
|
28
|
+
if (target.startsWith("/")) {
|
|
29
|
+
return new URL(`${TARGET_BASE}${target}`);
|
|
30
|
+
}
|
|
31
|
+
if (ABSOLUTE_FORM.test(target)) {
|
|
32
|
+
return new URL(target);
|
|
33
|
+
}
|
|
34
|
+
return new URL(`${TARGET_BASE}/${target === "*" ? "" : target}`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return new URL(`${TARGET_BASE}/`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns the canonical path of a request-target.
|
|
42
|
+
*/
|
|
43
|
+
export function getCanonicalPath(target) {
|
|
44
|
+
return parseRequestTarget(target).pathname || "/";
|
|
45
|
+
}
|
|
46
|
+
function rawPathOf(target) {
|
|
47
|
+
let path = target;
|
|
48
|
+
if (ABSOLUTE_FORM.test(path)) {
|
|
49
|
+
const afterScheme = path.indexOf("//") + 2;
|
|
50
|
+
const slash = path.indexOf("/", afterScheme);
|
|
51
|
+
path = slash === -1 ? "/" : path.slice(slash);
|
|
52
|
+
}
|
|
53
|
+
const end = path.search(/[?#]/);
|
|
54
|
+
return end === -1 ? path : path.slice(0, end);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Explains why a request-target is refused, or returns `undefined` when it is
|
|
58
|
+
* acceptable.
|
|
59
|
+
*
|
|
60
|
+
* Refused: anything that is not origin-form, absolute-form (`http(s)://`) or
|
|
61
|
+
* the asterisk-form `*`; a backslash (WHATWG URL parsing treats it as a
|
|
62
|
+
* separator); and any `.` / `..` segment, plain or percent-encoded
|
|
63
|
+
* (`%2e%2e`). Those segments are resolved away by URL parsing, so a front
|
|
64
|
+
* proxy matching `/admin*` on the raw target and the router dispatching on
|
|
65
|
+
* the resolved path would otherwise disagree about the same request.
|
|
66
|
+
*/
|
|
67
|
+
export function findRequestTargetViolation(target) {
|
|
68
|
+
if (target === "*") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
if (!target.startsWith("/") && !ABSOLUTE_FORM.test(target)) {
|
|
72
|
+
return "Request target is not in origin-form or absolute-form.";
|
|
73
|
+
}
|
|
74
|
+
const path = rawPathOf(target);
|
|
75
|
+
if (path.includes("\\")) {
|
|
76
|
+
return "Request target contains a backslash.";
|
|
77
|
+
}
|
|
78
|
+
for (const segment of path.split("/")) {
|
|
79
|
+
const decoded = segment.replace(ENCODED_DOT, ".");
|
|
80
|
+
if (decoded === "." || decoded === "..") {
|
|
81
|
+
return "Request target contains a dot segment.";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether a request-target is acceptable (see
|
|
88
|
+
* {@link findRequestTargetViolation}).
|
|
89
|
+
*/
|
|
90
|
+
export function isCanonicalRequestTarget(target) {
|
|
91
|
+
return findRequestTargetViolation(target) === undefined;
|
|
92
|
+
}
|
|
93
|
+
//# 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
|
|
@@ -29,6 +29,16 @@ export declare class HttpResponseContext {
|
|
|
29
29
|
html(content: string): this;
|
|
30
30
|
cookie(name: string, value: string, options?: ResponseCookie["options"]): this;
|
|
31
31
|
removeCookie(name: string): this;
|
|
32
|
+
/**
|
|
33
|
+
* Redirects to `url`.
|
|
34
|
+
*
|
|
35
|
+
* The destination goes through `assertSafeRedirect`: a `javascript:` or
|
|
36
|
+
* `data:` URL, a scheme-relative `//evil.com`, or a value carrying a
|
|
37
|
+
* control character throws instead of being emitted as `Location`. An
|
|
38
|
+
* absolute `http(s)` URL and a same-origin path reference are accepted.
|
|
39
|
+
*
|
|
40
|
+
* @throws {TypeError} If the destination is not a safe redirect target.
|
|
41
|
+
*/
|
|
32
42
|
redirect(url: string, status?: number): this;
|
|
33
43
|
markSent(): void;
|
|
34
44
|
assertMutable(): void;
|