diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cde3e4..ef1534c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ permissions: jobs: release: runs-on: ubuntu-latest + concurrency: + group: release + cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@d9184bf108216479bc5a137cc391f4d7b14c870b # v6.1.0 @@ -31,6 +34,7 @@ jobs: node-version: 26 registry-url: https://registry.npmjs.org/ cache: pnpm + - run: pnpm install --frozen-lockfile - name: Bump and push version if: inputs.upgrade != 'existing' diff --git a/.gitignore b/.gitignore index 2cf5403..dcaea44 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules build dist *.tsbuildinfo +.cache # Test-generated certificate directories test/custom-folder/ diff --git a/README.md b/README.md index d94c81c..d3ba20e 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ Usage notes: - `--cert-path `: custom certificate directory (`CERT_PATH`) - `--reinstall`: force certificate re-generation (`REINSTALL=true`) - `--proxy `: proxy all requests to the given http(s) URL (`PROXY_TARGET`) + - `-h, --help`: display help - Specifying a port number prevents HTTP to HTTPS redirect. ## Use as module -Install as a dependency: +Install as a dev dependency: ```sh npm i -D https-localhost diff --git a/package.json b/package.json index 34bcfab..46fc885 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "types": "./src/certs.ts", "import": "./src/certs.ts", "default": "./src/certs.ts" - } + }, + "./package.json": "./package.json" }, "publishConfig": { "main": "dist/cjs/index.js", @@ -56,11 +57,14 @@ "import": "./dist/esm/certs.js", "require": "./dist/cjs/certs.js", "default": "./dist/esm/certs.js" - } + }, + "./package.json": "./package.json" } }, "scripts": { - "build": "rm -rf dist && tsc --project tsconfig.build.esm.json && tsc --project tsconfig.build.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "build": "pnpm run build:esm && pnpm run build:cjs", + "build:esm": "tsc --project tsconfig.build.esm.json && node -e \"fs.writeFileSync('dist/esm/package.json', JSON.stringify({ type: 'module' }))\"", + "build:cjs": "tsc --project tsconfig.build.cjs.json && node -e \"fs.writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\"", "fix": "oxfmt && oxlint --fix && knip && tsc --noEmit", "lint": "oxfmt --check && oxlint && knip && tsc --noEmit", "test": "node --test-concurrency=1 --test test/*.test.ts", @@ -93,5 +97,13 @@ "engines": { "node": ">=24" }, - "packageManager": "pnpm@12.3.4" + "packageManager": "pnpm@12.3.4", + "pkg": { + "scripts": "*.js", + "targets": [ + "node16-macos-x64", + "node16-linux-x64", + "node16-win-x64" + ] + } } diff --git a/src/certs.ts b/src/certs.ts index 44fe968..3802444 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -17,46 +17,98 @@ export type CertificatePair = { function getExe(): string { switch (process.platform) { case "darwin": + if (process.arch === "arm64") return `mkcert-${MKCERT_VERSION}-darwin-arm64` return `mkcert-${MKCERT_VERSION}-darwin-amd64` case "linux": - if (process.arch === "arm" || process.arch === "arm64") { - return `mkcert-${MKCERT_VERSION}-linux-arm` - } + if (process.arch === "arm64") return `mkcert-${MKCERT_VERSION}-linux-arm64` + if (process.arch === "arm") return `mkcert-${MKCERT_VERSION}-linux-arm` return `mkcert-${MKCERT_VERSION}-linux-amd64` case "win32": return `mkcert-${MKCERT_VERSION}-windows-amd64.exe` default: - console.error( - "Cannot generate the localhost certificate on your " + - "platform. Please, consider contacting the developer if you can help.", + throw new Error( + "Cannot generate the localhost certificate on your platform " + + `(${process.platform}-${process.arch}). Please, consider contacting the developer if you can help.`, ) - process.exit(0) + } +} + +const MAX_REDIRECTS = 5 + +// mkcert release binaries are a few MB; a truncated or empty cache (e.g. an +// interrupted download or an error page written by older versions) must be +// re-downloaded instead of failing exec with a cryptic error forever. +const MIN_EXECUTABLE_SIZE = 1024 * 1024 + +function isValidCachedExecutable(exePath: string): boolean { + try { + const stat = fs.statSync(exePath) + return ( + stat.isFile() && + stat.size >= MIN_EXECUTABLE_SIZE && + fs.accessSync(exePath, fs.constants.X_OK) === undefined + ) + } catch { + return false } } async function download(url: string, destination: string): Promise { console.log("Downloading the mkcert executable...") - const file = fs.createWriteStream(destination) + // Download to a temporary file and rename it into place: spawning a + // binary whose file descriptor is still open anywhere fails with ETXTBSY + // on Linux, and a rename gives the executable a fresh inode no writer + // holds. The temp file lives in the same directory so the rename is + // atomic. + const tempDestination = `${destination}.download-${process.pid}` return new Promise((resolve, reject) => { - function get(currentUrl: string): void { + function get(currentUrl: string, redirectsLeft: number): void { + let file: fs.WriteStream | undefined + function fail(error: Error): void { + file?.destroy() + fs.rmSync(tempDestination, { force: true }) + reject(error) + } https .get(currentUrl, response => { - if (response.statusCode === 302 && response.headers.location !== undefined) { - get(response.headers.location) + const { statusCode } = response + const location = response.headers.location + if ( + statusCode !== undefined && + statusCode >= 300 && + statusCode < 400 && + location !== undefined + ) { + response.resume() + if (redirectsLeft <= 0) { + fail(new Error(`Too many redirects while downloading ${url}`)) + return + } + get(new URL(location, currentUrl).toString(), redirectsLeft - 1) + return + } + if (statusCode !== 200) { + // Never write an error page into the executable file. + response.resume() + fail(new Error(`Failed to download ${currentUrl} (HTTP ${statusCode ?? "unknown"})`)) return } - response.pipe(file) - file.on("finish", () => { - file.close(err => { - if (err === undefined || err === null) resolve() - else reject(new Error("Failed to close the certificate file", { cause: err })) + const output = fs.createWriteStream(tempDestination) + file = output + response.pipe(output) + output.on("finish", () => { + output.close(err => { + if (err === undefined || err === null) { + fs.renameSync(tempDestination, destination) + resolve() + } else fail(new Error("Failed to close the certificate file", { cause: err })) }) }) - file.on("error", reject) + output.on("error", fail) }) - .on("error", reject) + .on("error", fail) } - get(url) + get(url, MAX_REDIRECTS) }) } @@ -78,16 +130,40 @@ async function runMkcert({ return new Promise((resolve, reject) => { console.log("Running mkcert to generate certificates...") - execFile(exePath, args, (error, stdout, stderr) => { - if (stdout.length > 0) console.log(stdout) - if (stderr.length > 0) console.error(stderr) - if (error !== null) { - console.error(error) - reject(new Error(`mkcert failed: ${error.message}`)) - return + // On Linux the freshly written executable may still be held by the + // downloader's fd for a moment (ETXTBSY); retry a few times before + // giving up. The error can surface either synchronously from execFile + // or through the callback. + const retry = (retriesLeft: number): void => { + setTimeout(() => attempt(retriesLeft), 250) + } + const attempt = (retriesLeft: number): void => { + let child: ReturnType + try { + child = execFile(exePath, args, (error, stdout, stderr) => { + if (stdout.length > 0) console.log(stdout) + if (stderr.length > 0) console.error(stderr) + if (error !== null) { + if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { + retry(retriesLeft - 1) + return + } + console.error(error) + reject(new Error(`mkcert failed: ${error.message}`)) + return + } + resolve() + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { + retry(retriesLeft - 1) + return + } + throw error } - resolve() - }) + child.on("error", () => {}) + } + attempt(5) }) } @@ -104,7 +180,9 @@ export async function generate({ const url = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/` const exe = getExe() const exePath = path.join(appDataPath, exe) - if (!fs.existsSync(exePath)) { + const cached = isValidCachedExecutable(exePath) + if (!cached) { + if (fs.existsSync(exePath)) fs.rmSync(exePath, { force: true }) await download(url + exe, exePath) fs.chmodSync(exePath, "0755") } diff --git a/src/cli.ts b/src/cli.ts index 7d6c2b5..4752513 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,24 +42,7 @@ if (proxy !== undefined && parsedArgs.positionals.length > 0) { process.exit(1) } -const env = getEnv({ - PORT: port, - HOST: host, - CERT_PATH: certPath, - REINSTALL: reinstall, - PROXY_TARGET: proxy, -}) - -const app = createServer({ - domain: env.HOST, - certPath: env.CERT_PATH, - reinstall: env.REINSTALL, -}) -if (env.PROXY_TARGET !== undefined) app.proxy(env.PROXY_TARGET, env.PORT) -else app.serve(staticFolder, env.PORT) -if (env.PORT === 443) app.redirect() - -process.on("uncaughtException", err => { +function friendlyError(err: unknown): void { const error = err as NodeJS.ErrnoException switch (error.code) { case "EACCES": @@ -82,4 +65,40 @@ process.on("uncaughtException", err => { break } process.exit(1) +} + +let env: ReturnType +try { + env = getEnv({ + PORT: port, + HOST: host, + CERT_PATH: certPath, + REINSTALL: reinstall, + PROXY_TARGET: proxy, + }) +} catch (err) { + const issues = + err !== null && typeof err === "object" && "issues" in err && Array.isArray(err.issues) + ? (err.issues as { path: (string | number | symbol)[]; message: string }[]) + : undefined + const summary = + issues === undefined + ? err instanceof Error + ? err.message + : String(err) + : issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join("\n") + console.error(`Invalid arguments or environment:\n${summary}`) + process.exit(1) +} + +process.on("unhandledRejection", friendlyError) +process.on("uncaughtException", friendlyError) + +const app = createServer({ + domain: env.HOST, + certPath: env.CERT_PATH, + reinstall: env.REINSTALL, }) +if (env.PROXY_TARGET !== undefined) app.proxy(env.PROXY_TARGET, env.PORT) +else app.serve(staticFolder, env.PORT) +if (env.PORT === 443) app.redirect() diff --git a/src/index.ts b/src/index.ts index df87de1..9fcaf2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,21 @@ import http from "node:http" -import type { Server } from "node:http" +import type { IncomingMessage, Server } from "node:http" import https from "node:https" +import type { Duplex } from "node:stream" import { getCerts } from "./certs.ts" import { createProxyHandler, createProxyUpgradeHandler } from "./proxy.ts" import { createRouter } from "./router.ts" import { createStaticHandler } from "./static.ts" +async function closeServer(server: Server): Promise { + server.closeAllConnections() + server.closeIdleConnections() + await new Promise(resolve => { + server.close(() => resolve()) + }) +} + export type HttpsLocalhostApp = { server?: Server http?: Server @@ -27,16 +36,36 @@ export function createServer({ } = {}): HttpsLocalhostApp { const router = createRouter() + // Listening errors (e.g. EADDRINUSE) must reject the caller's promise: + // without an error listener Node emits them uncaught and the promise + // never settles. + function listenWithEvents( + server: Server, + port: number, + onUpgrade?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void, + ): Promise { + server.on("error", error => { + server.emit("listenError", error) + }) + if (onUpgrade !== undefined) server.on("upgrade", onUpgrade) + return new Promise((resolve, reject) => { + server.once("listenError", reject) + server.listen(port, () => { + server.removeListener("listenError", reject) + resolve(server) + }) + }) + } + const app: HttpsLocalhostApp = { async listen(port = 443) { - const certs = await getCerts({ domain, certPath, reinstall }) - app.server = https.createServer(certs, router.handleRequest) - if (router.proxyUpgradeHandler !== undefined) { - app.server.on("upgrade", router.proxyUpgradeHandler) + if (app.server?.listening === true) { + console.warn(`Server already listening, closing the previous one.`) + await closeServer(app.server) } - await new Promise(resolve => { - app.server?.listen(port, resolve) - }) + const certs = await getCerts({ domain, certPath, reinstall }) + const server = https.createServer(certs, router.handleRequest) + app.server = await listenWithEvents(server, port, router.proxyUpgradeHandler) console.info(`Server running on port ${port}.`) return app.server }, @@ -48,19 +77,24 @@ export function createServer({ return app }, async redirect(httpPort = 80, httpsPort = 443) { - await new Promise(resolve => { - app.http = http - .createServer((req, res) => { - const reqHost = req.headers.host ?? domain - res.writeHead(301, { - Location: `https://${reqHost.replace(`:${httpPort}`, "")}${ - httpsPort !== 443 ? `:${httpsPort}` : "" - }${req.url ?? ""}`, - }) - res.end() - }) - .listen(httpPort, resolve) + if (app.http?.listening === true) { + console.warn(`Redirect server already listening, closing the previous one.`) + await closeServer(app.http) + } + const server = http.createServer((req, res) => { + const reqHost = req.headers.host ?? domain + // Strip the http port from the Host without touching an unrelated + // occurrence (e.g. inside an IPv6 literal) and handle hosts without + // a port. + const colonIndex = reqHost.lastIndexOf(":") + const hasPort = colonIndex > reqHost.lastIndexOf("]") + const bareHost = hasPort ? reqHost.slice(0, colonIndex) : reqHost + res.writeHead(301, { + Location: `https://${bareHost}${httpsPort !== 443 ? `:${httpsPort}` : ""}${req.url ?? ""}`, + }) + res.end() }) + app.http = await listenWithEvents(server, httpPort) console.info("http to https redirection active.") return app }, diff --git a/src/proxy.ts b/src/proxy.ts index 9470707..8b77b1e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,11 +19,58 @@ const HOP_BY_HOP_HEADERS: ReadonlySet = new Set([ "upgrade", ]) -function filterHeaders(headers: IncomingMessage["headers"]): OutgoingHttpHeaders { +// Headers listed in Connection (RFC 9110 §7.6.1) are hop-by-hop too and +// must not be forwarded. +function connectionListedHeaders( + connection: IncomingMessage["headers"]["connection"], +): Set { + const listed = new Set() + const values = + connection === undefined ? [] : Array.isArray(connection) ? connection : [connection] + for (const value of values) { + for (const token of value.split(",")) { + const name = token.trim().toLowerCase() + if (name !== "") listed.add(name) + } + } + return listed +} + +// A valid HTTP header name (RFC 9110 §5.1 token). Assigning an arbitrary +// string as a property name (e.g. "__proto__") on a plain object would be a +// prototype-pollution / property-injection hazard, so every header name is +// validated before it is written to the outgoing headers object. +const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/u + +function isSafeHeaderName(name: string): boolean { + return HEADER_NAME.test(name) && !Object.prototype.hasOwnProperty.call(Object.prototype, name) +} + +function filterHeaders( + headers: IncomingMessage["headers"], + { keepUpgradeHeaders = false }: { keepUpgradeHeaders?: boolean } = {}, +): OutgoingHttpHeaders { + const connectionListed = keepUpgradeHeaders + ? new Set() + : connectionListedHeaders(headers.connection) const filtered: OutgoingHttpHeaders = {} for (const [name, value] of Object.entries(headers)) { - if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue - filtered[name] = value + if (value === undefined) continue + const lowerName = name.toLowerCase() + if (!isSafeHeaderName(lowerName)) continue + if (HOP_BY_HOP_HEADERS.has(lowerName) || connectionListed.has(lowerName)) { + // On the upgrade path Connection and Upgrade must be preserved (see + // createProxyUpgradeHandler); everything else stays hop-by-hop. + if ( + keepUpgradeHeaders && + (lowerName === "connection" || lowerName === "upgrade") && + typeof value === "string" + ) { + filtered[lowerName] = value + } + continue + } + filtered[lowerName] = value } return filtered } @@ -97,7 +144,10 @@ export function createProxyUpgradeHandler( const transport = url.protocol === "https:" ? https : http const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port) return function handleUpgrade(req, clientSocket, head): void { - const headers = filterHeaders(req.headers) + // An upgrade request is itself a protocol upgrade: the Connection and + // Upgrade headers must be forwarded verbatim or the upstream will never + // see the upgrade request (RFC 9110 §7.8.1 and RFC 6455 §4.2.1). + const headers = filterHeaders(req.headers, { keepUpgradeHeaders: true }) headers.host = url.host const upstream = transport.request({ protocol: url.protocol, @@ -107,6 +157,21 @@ export function createProxyUpgradeHandler( method: req.method, headers, }) + // If the upstream answers without upgrading (e.g. 4xx/5xx), relay the + // status line and close both sockets: nobody else would consume the + // response and the client would hang forever otherwise. + upstream.on("response", upstreamRes => { + const statusLine = `HTTP/1.1 ${upstreamRes.statusCode ?? 502} ${upstreamRes.statusMessage ?? "Bad Gateway"}\r\n` + clientSocket.write(statusLine) + for (const [name, value] of Object.entries(filterHeaders(upstreamRes.headers))) { + if (value === undefined) continue + const values = Array.isArray(value) ? value : [value] + for (const item of values) clientSocket.write(`${name}: ${item}\r\n`) + } + clientSocket.write("\r\n") + clientSocket.end() + upstream.destroy() + }) upstream.on("upgrade", (upstreamRes, upstreamSocket, upstreamHead) => { clientSocket.write( `HTTP/1.1 ${upstreamRes.statusCode ?? 101} ${upstreamRes.statusMessage ?? "Switching Protocols"}\r\n`, @@ -119,6 +184,13 @@ export function createProxyUpgradeHandler( clientSocket.write("\r\n") if (upstreamHead.length > 0) clientSocket.write(upstreamHead) if (head.length > 0) upstreamSocket.write(head) + // When one side of the tunnel disconnects (or errors), tear down the + // other side too; without this, disconnected sockets linger and can + // crash the process on a socket error with no listener. + clientSocket.on("error", () => upstreamSocket.destroy()) + clientSocket.on("close", () => upstreamSocket.destroy()) + upstreamSocket.on("error", () => clientSocket.destroy()) + upstreamSocket.on("close", () => clientSocket.destroy()) clientSocket.pipe(upstreamSocket).pipe(clientSocket) }) upstream.on("error", () => clientSocket.destroy()) diff --git a/src/static.ts b/src/static.ts index 0a3e060..62aa2ff 100644 --- a/src/static.ts +++ b/src/static.ts @@ -28,8 +28,11 @@ function sanitize(staticPath: string, urlPath: string): string | null { } function parseRange(header: string, size: number): { start: number; end: number } | null { - const firstRange = header.trim().split(",", 1)[0] - const match = /^bytes=(\d*)-(\d*)$/u.exec(firstRange ?? "") + const trimmed = header.trim() + // Multi-range requests are answered with the full representation (RFC 9110 + // §14.2 allows a server to ignore Range). + if (trimmed.includes(",")) return null + const match = /^bytes=(\d*)-(\d*)$/u.exec(trimmed) if (match === null || (match[1] === "" && match[2] === "")) return null if (match[1] === "") { const n = Number(match[2]) @@ -57,53 +60,77 @@ function serve404(staticPath: string, req: IncomingMessage, res: ServerResponse) } } +// The lexical sanitize() check does not catch symlinks pointing outside the +// static root. After resolving the real path, verify it still lives beneath +// the (real) root; otherwise attackers able to plant a symlink in the served +// tree can read arbitrary readable files (e.g. via an uploaded site folder). +function confine(root: string, target: string): string | null { + try { + const realRoot = fs.realpathSync(root) + const realTarget = fs.realpathSync(target) + if (realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${path.sep}`)) return null + return realTarget + } catch { + return null + } +} + function serveFile( target: string, req: IncomingMessage, res: ServerResponse, range: { start: number; end: number } | null, ): void { - const stat = fs.statSync(target) - const etag = `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"` - res.setHeader("ETag", etag) - res.setHeader("Last-Modified", stat.mtime.toUTCString()) - res.setHeader("Accept-Ranges", "bytes") + // Open first, then fstat the descriptor: stat and read refer to the same + // open file, so the metadata cannot drift from the content between the + // check and the read (TOCTOU). + const fd = fs.openSync(target, "r") + try { + const stat = fs.fstatSync(fd) + const etag = `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"` + res.setHeader("ETag", etag) + res.setHeader("Last-Modified", stat.mtime.toUTCString()) + res.setHeader("Accept-Ranges", "bytes") - const ifNoneMatch = req.headers["if-none-match"] - if ( - ifNoneMatch?.split(",").some(tag => tag.trim() === etag || tag.trim() === `W/${etag}`) === true - ) { - res.writeHead(304) - res.end() - return - } - const ifModifiedSince = req.headers["if-modified-since"] - if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) { - if (stat.mtime <= new Date(ifModifiedSince)) { + const ifNoneMatch = req.headers["if-none-match"] + if ( + ifNoneMatch?.split(",").some(tag => tag.trim() === etag || tag.trim() === `W/${etag}`) === + true + ) { res.writeHead(304) res.end() return } - } + const ifModifiedSince = req.headers["if-modified-since"] + if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) { + if (stat.mtime <= new Date(ifModifiedSince)) { + res.writeHead(304) + res.end() + return + } + } - const { size } = stat - const content = fs.readFileSync(target) - const body = range === null ? content : content.subarray(range.start, range.end + 1) - res.writeHead( - range === null ? 200 : 206, - Object.assign( - { - "Content-Type": mime(target), - "Content-Length": range === null ? size : range.end - range.start + 1, - }, - range === null ? {} : { "Content-Range": `bytes ${range.start}-${range.end}/${size}` }, - ), - ) - if (req.method === "HEAD") { - res.end() - return + const { size } = stat + const content = fs.readFileSync(fd) + const body = range === null ? content : content.subarray(range.start, range.end + 1) + res.writeHead( + range === null ? 200 : 206, + Object.assign( + { + "Content-Type": mime(target), + "Content-Length": range === null ? size : range.end - range.start + 1, + }, + range === null ? {} : { "Content-Range": `bytes ${range.start}-${range.end}/${size}` }, + ), + ) + if (req.method === "HEAD") { + res.end() + return + } + res.end(body) + } finally { + fs.closeSync(fd) } - res.end(body) } export function createStaticHandler(staticPath: string): RequestListener { @@ -139,10 +166,19 @@ export function createStaticHandler(staticPath: string): RequestListener { const stat = fs.statSync(target) if (stat.isDirectory()) { if (urlPath?.endsWith("/") !== true) { - const relativeUrlPath = urlPath?.replace(/^\//u, "") ?? "" - res.writeHead(301, { - Location: encodeURI(`./${relativeUrlPath}/${query === "" ? "" : `?${query}`}`), - }) + // Re-encode the user-controlled path and only redirect when the + // result matches a strict same-site location shape: printable ASCII + // (percent-encoded) characters only, single root, never + // protocol-relative. + const location = encodeURI( + `/${(urlPath ?? "/").replace(/^\//u, "")}/${query === "" ? "" : `?${query}`}`, + ) + if (!/^\/(?!\/)[\x20-\x7e]*$/u.test(location)) { + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Bad request.") + return + } + res.writeHead(301, { Location: location }) res.end() return } @@ -152,6 +188,13 @@ export function createStaticHandler(staticPath: string): RequestListener { serve404(staticPath, req, res) return } + const confined = confine(staticPath, target) + if (confined === null) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Forbidden.") + return + } + target = confined try { fs.statSync(target) } catch { @@ -161,8 +204,14 @@ export function createStaticHandler(staticPath: string): RequestListener { let range: { start: number; end: number } | null = null const rangeHeader = req.headers.range if (rangeHeader !== undefined) { - range = parseRange(rangeHeader, fs.statSync(target).size) - if (range === null) { + // null means ignore the header (serve 200), not an error: malformed or + // unsupported (e.g. multi-range) requests get the full representation. + const parsed = parseRange(rangeHeader, fs.statSync(target).size) + if ( + parsed === null && + rangeHeader.trim().startsWith("bytes=") && + !rangeHeader.includes(",") + ) { res.writeHead(416, { "Content-Range": `bytes */${fs.statSync(target).size}`, "Content-Type": "text/plain; charset=utf-8", @@ -170,6 +219,7 @@ export function createStaticHandler(staticPath: string): RequestListener { res.end("Range not satisfiable.") return } + range = parsed } serveFile(target, req, res, range) } diff --git a/test/fixtures/sub/deep/index.html b/test/fixtures/sub/deep/index.html new file mode 100644 index 0000000..f29645e --- /dev/null +++ b/test/fixtures/sub/deep/index.html @@ -0,0 +1,8 @@ + + + Deep + + +

Deep index.

+ + diff --git a/test/index.test.ts b/test/index.test.ts index 4e26c73..c8c2904 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert" import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { afterEach, describe, it } from "node:test" import { generate, remove } from "../src/certs.ts" @@ -110,7 +112,7 @@ void describe("index (createServer)", { timeout: 300000 }, () => { const res = await makeRequest("/sub") assert.strictEqual(res.statusCode, 301) - assert.strictEqual(res.headers["location"], "./sub/") + assert.strictEqual(res.headers["location"], "/sub/") const index = await makeRequest("/sub/") assert.strictEqual(index.statusCode, 200) @@ -126,7 +128,38 @@ void describe("index (createServer)", { timeout: 300000 }, () => { const res = await makeRequest("/sub?a=1") assert.strictEqual(res.statusCode, 301) - assert.strictEqual(res.headers["location"], "./sub/?a=1") + assert.strictEqual(res.headers["location"], "/sub/?a=1") + }) + + void it("redirects nested directories to a resolvable location", async () => { + app = createServer() + await app.serve("test/fixtures", HTTPS_PORT) + + const res = await makeRequest("/sub/deep") + assert.strictEqual(res.statusCode, 301) + assert.strictEqual(res.headers["location"], "/sub/deep/") + + const index = await makeRequest(res.headers["location"] ?? "/") + assert.strictEqual(index.statusCode, 200) + }) + + void it("rejects symlinked paths escaping the served root", async () => { + app = createServer() + await app.serve("test/fixtures", HTTPS_PORT) + + // A random directory outside the served root; not a predictable file in + // the shared temp dir. + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "https-localhost-outside-")) + const outsideFile = path.join(outsideDir, "secret.txt") + fs.writeFileSync(outsideFile, "TOPSECRET") + fs.symlinkSync(outsideFile, "test/fixtures/symlink-secret.txt") + try { + const res = await makeRequest("/symlink-secret.txt") + assert.strictEqual(res.statusCode, 403) + } finally { + fs.rmSync("test/fixtures/symlink-secret.txt") + fs.rmSync(outsideDir, { recursive: true, force: true }) + } }) void it("rejects protocol-relative and absolute request targets", async () => { @@ -190,6 +223,15 @@ void describe("index (createServer)", { timeout: 300000 }, () => { assert.strictEqual(res.headers["location"], `https://localhost:${HTTPS_PORT}/`) }) + void it("redirects http to https for an IPv6 host", async () => { + app = createServer() + await app.redirect(HTTP_PORT, HTTPS_PORT) + + const res = await makeRequest("/", false, HTTP_PORT, { host: "[::1]:8080" }) + assert.strictEqual(res.statusCode, 301) + assert.strictEqual(res.headers["location"], `https://[::1]:${HTTPS_PORT}/`) + }) + void it("doesn't install an uncaughtException handler when imported", async () => { const listenersBefore = process.listenerCount("uncaughtException") await import(`../src/index.ts?t=${Date.now()}`) diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 801bd5c..7d30fe5 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -96,4 +96,145 @@ void describe("proxy", { timeout: 300000 }, () => { void it("throws for unsupported proxy protocols", async () => { await assert.rejects(() => app.proxy("ftp://localhost:3000", HTTPS_PORT)) }) + + void it("drops Connection-listed hop-by-hop headers", async () => { + let seen: Record = {} + upstream = http.createServer((req, res) => { + seen = req.headers + res.writeHead(200, { "x-resp-listed": "nope" }) + res.end("ok") + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const res = await new Promise<{ headers: Record }>( + (resolve, reject) => { + const req = https.request( + `https://localhost:${HTTPS_PORT}/`, + { + agent: false, + ca: [rootCA], + headers: { connection: "x-secret, keep-alive", "x-secret": "leak" }, + }, + upstreamRes => { + const headers = upstreamRes.headers + upstreamRes.resume() + upstreamRes.on("end", () => resolve({ headers })) + }, + ) + req.on("error", reject) + req.end() + }, + ) + + assert.strictEqual(seen["x-secret"], undefined, "x-secret must not reach the upstream") + assert.strictEqual(res.headers["x-resp-listed"], "nope") + }) + + void it("relays a non-101 response to a WebSocket upgrade instead of hanging", async () => { + upstream = http.createServer((_req, res) => { + res.writeHead(400) + res.end("no upgrades here") + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const result = await new Promise(resolve => { + const timer = setTimeout(() => resolve("hang"), 5000) + const req = https.request(`https://localhost:${HTTPS_PORT}/ws`, { + agent: false, + ca: [rootCA], + headers: { connection: "Upgrade", upgrade: "websocket" }, + }) + req.end() + req.on("upgrade", () => { + clearTimeout(timer) + resolve("upgraded") + }) + req.on("response", res => { + res.resume() + res.on("end", () => { + // The Node client keeps the socket half-open after a response to an + // upgrade request; destroy it or the test process never exits. + req.destroy() + clearTimeout(timer) + resolve(`response:${res.statusCode}`) + }) + }) + req.on("error", err => { + clearTimeout(timer) + resolve(`error:${(err as NodeJS.ErrnoException).code}`) + }) + }) + assert.notStrictEqual(result, "hang", "upgrade request hung on non-101 upstream") + assert.strictEqual(result, "response:400") + }) + + void it("proxies WebSocket upgrades with the upgrade headers intact", async () => { + let sawUpgradeHeader = false + upstream = http.createServer() + upstream.on("upgrade", (req, socket) => { + sawUpgradeHeader = req.headers.upgrade === "websocket" + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n", + ) + socket.on("data", () => socket.end("hello-from-upstream")) + socket.on("close", () => socket.destroy()) + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const result = await new Promise<{ data: string; got101: boolean }>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("upgrade timed out")), 5000) + const req = https.request(`https://localhost:${HTTPS_PORT}/ws`, { + agent: false, + ca: [rootCA], + headers: { connection: "Upgrade", upgrade: "websocket" }, + }) + req.end() + req.on("upgrade", (res, socket, head) => { + let data = head.toString() + socket.on("data", (chunk: Buffer) => { + data += chunk.toString() + socket.destroy() + }) + socket.on("close", () => { + clearTimeout(timer) + resolve({ data, got101: res.statusCode === 101 }) + }) + socket.on("error", err => { + clearTimeout(timer) + reject(err) + }) + // Ask the "server" to send something back: write a frame. + socket.write("ping") + }) + req.on("error", err => { + clearTimeout(timer) + reject(err) + }) + }) + + assert.ok(result.got101, "expected 101 Switching Protocols") + assert.ok(sawUpgradeHeader, "upstream must receive the Upgrade header") + assert.notStrictEqual(result.data, "", "some data must flow after the upgrade") + }) }) diff --git a/tsconfig.build.cjs.json b/tsconfig.build.cjs.json index ddbf37d..7258f04 100644 --- a/tsconfig.build.cjs.json +++ b/tsconfig.build.cjs.json @@ -9,6 +9,7 @@ "moduleResolution": "Bundler", "noEmit": false, "outDir": "dist/cjs", + "tsBuildInfoFile": ".cache/tsc/tsconfig.build.cjs.tsbuildinfo", "verbatimModuleSyntax": false }, "include": ["src/**/*.ts"] diff --git a/tsconfig.build.esm.json b/tsconfig.build.esm.json index c24b3ee..b4475e9 100644 --- a/tsconfig.build.esm.json +++ b/tsconfig.build.esm.json @@ -9,6 +9,7 @@ "moduleResolution": "Bundler", "noEmit": false, "outDir": "dist/esm", + "tsBuildInfoFile": ".cache/tsc/tsconfig.build.esm.tsbuildinfo", "verbatimModuleSyntax": true }, "include": ["src/**/*.ts"] diff --git a/tsconfig.json b/tsconfig.json index 9919f6c..601648f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "erasableSyntaxOnly": true, "forceConsistentCasingInFileNames": true, "incremental": true, + "tsBuildInfoFile": ".cache/tsc/tsconfig.tsbuildinfo", "isolatedModules": true, "lib": ["ES2023"], "module": "NodeNext",