From 1eff6972a54699b4cf9a4e62ac5ff3984490396f Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 13:27:43 -0400 Subject: [PATCH] fix: validate static asset request hosts --- src/request.ts | 15 ++++++++++----- src/serve.ts | 3 ++- tests/node.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/request.ts b/src/request.ts index 52b08a7..9b88a34 100644 --- a/src/request.ts +++ b/src/request.ts @@ -13,11 +13,7 @@ function requestHeaders(request: IncomingMessage): Headers { return headers; } -export function requestFromNode( - request: IncomingMessage, - options: NodeHandlerOptions, - signal: AbortSignal, -): Request { +export function resolveNodeRequestUrl(request: IncomingMessage, options: NodeHandlerOptions): URL { const host = request.headers.host; if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist."); @@ -32,6 +28,15 @@ export function requestFromNode( const target = new URL(request.url ?? "/", base); if (/^https?:\/\//i.test(request.url ?? "") && target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed."); + return target; +} + +export function requestFromNode( + request: IncomingMessage, + options: NodeHandlerOptions, + signal: AbortSignal, +): Request { + const target = resolveNodeRequestUrl(request, options); const method = request.method ?? "GET"; const body = method === "GET" || method === "HEAD" diff --git a/src/serve.ts b/src/serve.ts index 2602dd2..1bdbfb4 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -6,6 +6,7 @@ import type { ServerApp } from "@askrjs/server"; import type { ServeOptions, ServedApplication } from "./contracts.js"; import { formatHostForUrl, resolveBindHost } from "./bind.js"; import { createNodeHandler } from "./handler.js"; +import { resolveNodeRequestUrl } from "./request.js"; import { installWebSockets } from "./websocket.js"; const mimeTypes: Readonly> = { @@ -66,7 +67,7 @@ export async function serve( const server = createServer(async (request, response) => { let pathname: string; try { - pathname = decodeURIComponent(new URL(request.url ?? "/", "http://askr.local").pathname); + pathname = decodeURIComponent(resolveNodeRequestUrl(request, handlerOptions).pathname); } catch { response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request"); return; diff --git a/tests/node.test.ts b/tests/node.test.ts index 68a31ec..d10d390 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -428,6 +428,28 @@ describe("serve", () => { expect(asset.headers.get("x-content-type-options")).toBe("nosniff"); expect((await fetch(`${served.url}/missing.js`)).status).toBe(404); expect(fallthrough).toBe(0); + const address = served.server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP address"); + const send = (path: string, host: string) => + new Promise((resolve, reject) => { + const request = nodeRequest({ + host: "127.0.0.1", + port: address.port, + path, + headers: { host }, + }); + request.once("response", (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + request.once("error", reject); + request.end(); + }); + await expect(send("/app-12345678.js", "evil.example")).resolves.toBe(400); + await expect( + send("http://evil.example/app-12345678.js", `127.0.0.1:${address.port}`), + ).resolves.toBe(400); + expect(fallthrough).toBe(0); expect(await (await fetch(`${served.url}/page`)).text()).toBe("app"); expect(fallthrough).toBe(1); } finally {