diff --git a/src/server.ts b/src/server.ts index 3ae70ea..73469a4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -126,6 +126,122 @@ function hasParameter(parameters: Record, name: string) { return Object.getOwnPropertyDescriptor(parameters, name) !== undefined; } +type RequestProtocol = "http" | "https" | "ws" | "wss"; + +const commonPathname = /^\/[A-Za-z0-9/_-]*$/; +const commonHost = /^[A-Za-z0-9.-]+(?::[0-9]+)?$/; +const commonDomainHost = /^[A-Za-z]/; +const commonIpv4 = /^(?:0|[1-9][0-9]{0,2})(?:\.(?:0|[1-9][0-9]{0,2})){3}$/; +const safePathname = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/%]*$/; +const dotPathSegment = /(?:^|\/)(?:(?:\.|%2e){1,2})(?:\/|$)/i; +const requestHost = Symbol(); +const requestTarget = Symbol(); +const requestUrl = Symbol(); + +interface LazyRequestContext extends RequestContext { + [requestHost]: string; + [requestTarget]: string; + [requestUrl]?: URL; +} + +function createRequestUrlDescriptor( + protocol: RequestProtocol, +): PropertyDescriptor { + return { + configurable: true, + enumerable: true, + get(this: LazyRequestContext) { + this[requestUrl] ??= new URL( + this[requestTarget], + `${protocol}://${this[requestHost]}`, + ); + return this[requestUrl]; + }, + set(this: LazyRequestContext, url: URL) { + this[requestUrl] = url; + }, + }; +} + +const requestUrlDescriptors: Record = { + http: createRequestUrlDescriptor("http"), + https: createRequestUrlDescriptor("https"), + ws: createRequestUrlDescriptor("ws"), + wss: createRequestUrlDescriptor("wss"), +}; + +function isCommonIpv4(hostname: string): boolean { + if (!commonIpv4.test(hostname)) { + return false; + } + let octet = 0; + for (const character of hostname) { + if (character === ".") { + octet = 0; + } else { + octet = octet * 10 + character.charCodeAt(0) - 48; + if (octet > 255) { + return false; + } + } + } + return true; +} + +function isCommonHost(host: string): boolean { + if (!commonHost.test(host)) { + return false; + } + const portDelimiter = host.lastIndexOf(":"); + if (portDelimiter >= 0 && Number(host.slice(portDelimiter + 1)) > 65_535) { + return false; + } + const hostname = host.slice(0, portDelimiter < 0 ? undefined : portDelimiter); + if (commonDomainHost.test(hostname)) { + return true; + } + return isCommonIpv4(hostname); +} + +function createRequestContext( + req: IncomingMessage, + res: ServerResponse, + protocol: RequestProtocol, +): RequestContext { + const context = { + rawRequest: req, + rawResponse: res, + [requestHost]: req.headers.host || "localhost", + [requestTarget]: req.url || "", + routeParameters: {}, + response: new HTTPResult(404, "Not Found"), + } as unknown as LazyRequestContext; + Object.defineProperty(context, "url", requestUrlDescriptors[protocol]); + if (!isCommonHost(context[requestHost])) { + void context.url; + } + return context; +} + +function getPathname(requestContext: RequestContext): string { + const requestTarget = requestContext.rawRequest.url; + if (!requestTarget || requestTarget.startsWith("//")) { + return requestContext.url.pathname; + } + + const delimiterIndex = requestTarget.search(/[?#]/); + const pathname = requestTarget.slice( + 0, + delimiterIndex < 0 ? undefined : delimiterIndex, + ); + if (commonPathname.test(pathname)) { + return pathname; + } + return safePathname.test(pathname) && !dotPathSegment.test(pathname) + ? pathname + : requestContext.url.pathname; +} + function findHandlers( path: string[], depth: number, @@ -828,27 +944,13 @@ function processRequest( res: ServerResponse, protocol: "http" | "https", ): Awaitable { - const url = new URL( - req.url || "", - `${protocol}://${req.headers.host || "localhost"}`, - ); - const requestContext: RequestContext = { - rawRequest: req, - rawResponse: res, - url, - routeParameters: {}, - response: new HTTPResult(404, "Not Found"), - }; - const path = url.pathname.split("/").filter((part) => part); + const requestContext = createRequestContext(req, res, protocol); + const pathname = getPathname(requestContext); + const path = pathname.split("/").filter(Boolean); const method = req.method?.toLowerCase() || "get"; try { - const execution = executeRequest( - method, - path, - url.pathname, - requestContext, - ); + const execution = executeRequest(method, path, pathname, requestContext); const then = getThen(execution); if (then) { return resolveThenable(execution, then).then( @@ -887,19 +989,9 @@ export async function upgradeListener( protocol: "ws" | "wss", ) { const res = new ServerResponse(req); - const url = new URL( - req.url || "", - `${protocol}://${req.headers.host || "localhost"}`, - ); - const requestContext: RequestContext = { - rawRequest: req, - rawResponse: res, - url, - routeParameters: {}, - response: new HTTPResult(404, "Not Found"), - }; - - const path = url.pathname.split("/").filter((part) => part); + const requestContext = createRequestContext(req, res, protocol); + const pathname = getPathname(requestContext); + const path = pathname.split("/").filter(Boolean); const method = req.method?.toLowerCase() || "get"; let requestError: unknown; let hasUpgradedConnection = false; @@ -907,13 +999,7 @@ export async function upgradeListener( let mustDestroySocket = false; try { - const handler = getHandler( - method, - path, - roots.websocket, - false, - url.pathname, - ); + const handler = getHandler(method, path, roots.websocket, false, pathname); if (!handler || Array.isArray(handler)) { mustSendResponse = true; mustDestroySocket = true; diff --git a/src/test/request-context.test.ts b/src/test/request-context.test.ts new file mode 100644 index 0000000..80efe63 --- /dev/null +++ b/src/test/request-context.test.ts @@ -0,0 +1,307 @@ +import assert from "node:assert"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { connect } from "node:net"; +import WebSocket from "ws"; +import { + registerHandler, + requestListener, + unregisterHandler, + upgradeListener, +} from "../server"; + +const handlerIds: string[] = []; +let server: Server; +let port: number; + +function register( + id: string, + mode: "handler" | "monitor" | "websocket", + location: string, + handler: Parameters[4], +): void { + handlerIds.push(id); + registerHandler(id, mode, "get", location, handler); +} + +function sendRawRequest(target: string, host?: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1"); + let response = ""; + socket.setEncoding("utf8"); + socket.on("connect", () => { + const hostHeader = host === undefined ? "" : `Host: ${host}\r\n`; + socket.end(`GET ${target} HTTP/1.0\r\n${hostHeader}\r\n`); + }); + socket.on("data", (chunk) => { + response += chunk; + }); + socket.on("end", () => resolve(response.split("\r\n\r\n")[1] ?? "")); + socket.on("error", reject); + }); +} + +describe("Request context URL", () => { + before(async () => { + server = createServer((req, res) => requestListener(req, res, "http")); + server.on("upgrade", (req, socket, head) => + upgradeListener(req, socket, head, "ws"), + ); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + after(async () => { + handlerIds.forEach(unregisterHandler); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("keeps url enumerable without reading it", async () => { + register( + "lazy-url-descriptor", + "handler", + "/lazy/descriptor", + (context) => { + const descriptor = Object.getOwnPropertyDescriptor(context, "url"); + return { + enumerable: descriptor?.enumerable, + hasGetter: typeof descriptor?.get === "function", + keys: Object.keys(context), + }; + }, + ); + + const body = await sendRawRequest("/lazy/descriptor", "example.test"); + const result = JSON.parse(body); + assert.equal(result.enumerable, true); + assert.equal(result.hasGetter, true); + assert.ok(result.keys.includes("url")); + }); + + it("preserves encoded paths, query decoding, host and protocol", async () => { + register( + "lazy-url-encoded", + "handler", + "/lazy/encoded/:value", + (context) => ({ + host: context.url.host, + protocol: context.url.protocol, + query: context.url.searchParams.getAll("x"), + pathname: context.url.pathname, + value: context.routeParameters.value, + }), + ); + + const body = await sendRawRequest( + "/lazy/encoded/caf%C3%A9?x=a%2Bb&x=%2F", + "example.test:8080", + ); + assert.deepEqual(JSON.parse(body), { + host: "example.test:8080", + protocol: "http:", + query: ["a+b", "/"], + pathname: "/lazy/encoded/caf%C3%A9", + value: "caf%C3%A9", + }); + }); + + it("uses WHATWG normalization for dot segments", async () => { + register( + "lazy-url-dot", + "handler", + "/lazy/dot", + (context) => context.url.pathname, + ); + + const body = await sendRawRequest("/lazy/skipped/../dot", "example.test"); + assert.equal(body, "/lazy/dot"); + }); + + it("supports absolute request targets", async () => { + register( + "lazy-url-absolute", + "handler", + "/lazy/absolute", + (context) => context.url.href, + ); + + const body = await sendRawRequest( + "http://absolute.example/lazy/absolute?q=one%20two", + "ignored.example", + ); + assert.equal(body, "http://absolute.example/lazy/absolute?q=one%20two"); + }); + + it("supports protocol-relative request targets", async () => { + register( + "lazy-url-protocol-relative", + "handler", + "/lazy/protocol-relative", + (context) => context.url.origin, + ); + + const body = await sendRawRequest( + "//relative.example/lazy/protocol-relative", + "ignored.example", + ); + assert.equal(body, "http://relative.example"); + }); + + it("falls back to localhost when Host is absent", async () => { + register( + "lazy-url-hostless", + "handler", + "/lazy/hostless", + (context) => context.url.origin, + ); + + const body = await sendRawRequest("/lazy/hostless"); + assert.equal(body, "http://localhost"); + }); + + it("rejects invalid numeric hosts before routing", async () => { + let handlerWasCalled = false; + register("lazy-url-invalid-host", "handler", "/lazy/invalid-host", () => { + handlerWasCalled = true; + }); + const request = { + headers: { host: "999.999.999.999" }, + method: "GET", + url: "/lazy/invalid-host", + } as IncomingMessage; + + await assert.rejects( + async () => requestListener(request, {} as ServerResponse, "http"), + TypeError, + ); + assert.equal(handlerWasCalled, false); + }); + + it("returns the same URL instance across repeated synchronous accesses", async () => { + register("lazy-url-identity", "handler", "/lazy/identity", (context) => { + const first = context.url; + return { + pathname: context.url.pathname, + query: context.url.searchParams.get("value"), + sameInstance: first === context.url, + }; + }); + + const body = await sendRawRequest( + "/lazy/identity?value=repeated", + "example.test", + ); + assert.deepEqual(JSON.parse(body), { + pathname: "/lazy/identity", + query: "repeated", + sameInstance: true, + }); + }); + + it("keeps lazy URL semantics in asynchronous handlers", async () => { + register("lazy-url-async", "handler", "/lazy/async", async (context) => { + await Promise.resolve(); + return context.url.href; + }); + + const body = await sendRawRequest("/lazy/async?value=ok", "example.test"); + assert.equal(body, "http://example.test/lazy/async?value=ok"); + }); + + it("keeps url assignable", async () => { + register( + "lazy-url-assignable", + "handler", + "/lazy/assignable", + (context) => { + context.url = new URL("https://replacement.example/assigned"); + return context.url.href; + }, + ); + + const body = await sendRawRequest("/lazy/assignable", "original.example"); + assert.equal(body, "https://replacement.example/assigned"); + }); + + it("snapshots the request target and host before lazy access", async () => { + register("lazy-url-snapshot", "handler", "/lazy/snapshot", (context) => { + context.rawRequest.url = "/mutated"; + context.rawRequest.headers.host = "mutated.example"; + return context.url.href; + }); + + const body = await sendRawRequest( + "/lazy/snapshot?original=true", + "original.example", + ); + assert.equal(body, "http://original.example/lazy/snapshot?original=true"); + }); + + it("preserves encoded URL semantics for WebSocket upgrades", async () => { + register( + "lazy-url-websocket", + "websocket", + "/lazy/socket/:value", + (context) => { + const connection = context.connection as WebSocket; + connection.send( + JSON.stringify({ + pathname: context.url.pathname, + protocol: context.url.protocol, + value: context.routeParameters.value, + }), + ); + }, + ); + + const result = await new Promise((resolve, reject) => { + const socket = new WebSocket( + `ws://127.0.0.1:${port}/lazy/socket/caf%C3%A9?value=a%2Fb`, + ); + socket.once("message", (message) => { + resolve(message.toString()); + socket.close(); + }); + socket.once("error", reject); + }); + assert.deepEqual(JSON.parse(result), { + pathname: "/lazy/socket/caf%C3%A9", + protocol: "ws:", + value: "caf%C3%A9", + }); + }); + + it("preserves url through the monitor context spread", async () => { + let monitorResult: Record | undefined; + register( + "lazy-url-monitor-handler", + "handler", + "/lazy/monitor", + () => "ok", + ); + register("lazy-url-monitor", "monitor", "/lazy/monitor", (context) => { + const descriptor = Object.getOwnPropertyDescriptor(context, "url"); + monitorResult = { + enumerable: descriptor?.enumerable, + isDataProperty: "value" in (descriptor ?? {}), + isUrl: context.url instanceof URL, + pathname: context.url.pathname, + }; + }); + + await sendRawRequest("/lazy/monitor?observed=true", "example.test"); + assert.deepEqual(monitorResult, { + enumerable: true, + isDataProperty: true, + isUrl: true, + pathname: "/lazy/monitor", + }); + }); +});