diff --git a/src/certs.ts b/src/certs.ts index 2921c2b..c5812b4 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -37,15 +37,17 @@ function checkUpdates(): void { body += chunk.toString("utf8") }) res.on("end", () => { - const currentVersion: unknown = JSON.parse( - fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"), - ) - const latestVersion: unknown = JSON.parse(body) - if (!isRelease(latestVersion)) return - const current = isVersioned(currentVersion) ? (currentVersion.version ?? "") : "" - if (current !== latestVersion.tag_name.replace("v", "")) { - console.warn("[https-localhost] New update available.") - } + try { + const currentVersion: unknown = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"), + ) + const latestVersion: unknown = JSON.parse(body) + if (!isRelease(latestVersion)) return + const current = isVersioned(currentVersion) ? (currentVersion.version ?? "") : "" + if (current !== latestVersion.tag_name.replace("v", "")) { + console.warn("[https-localhost] New update available.") + } + } catch {} }) }) .end() diff --git a/src/index.ts b/src/index.ts index 1f8f874..df87de1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { Server } from "node:http" import https from "node:https" import { getCerts } from "./certs.ts" -import { createProxyHandler } from "./proxy.ts" +import { createProxyHandler, createProxyUpgradeHandler } from "./proxy.ts" import { createRouter } from "./router.ts" import { createStaticHandler } from "./static.ts" @@ -31,6 +31,9 @@ export function createServer({ 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) + } await new Promise(resolve => { app.server?.listen(port, resolve) }) @@ -39,6 +42,7 @@ export function createServer({ }, async proxy(target, port = 443) { router.setProxyHandler(createProxyHandler(target)) + router.setProxyUpgradeHandler(createProxyUpgradeHandler(target)) console.info(`Proxying to ${target}`) await app.listen(port) return app diff --git a/src/proxy.ts b/src/proxy.ts index e31a02a..9470707 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -6,6 +6,7 @@ import type { ServerResponse, } from "node:http" import https from "node:https" +import type { Duplex } from "node:stream" const HOP_BY_HOP_HEADERS: ReadonlySet = new Set([ "connection", @@ -36,10 +37,20 @@ export function createProxyHandler(target: string): RequestListener { const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port) return function handleProxy(req: IncomingMessage, res: ServerResponse): void { + if (req.method === "OPTIONS") { + res.writeHead(204) + res.end() + return + } const headers = filterHeaders(req.headers) headers.host = url.host - if (req.socket.remoteAddress !== undefined) - headers["x-forwarded-for"] = req.socket.remoteAddress + if (req.socket.remoteAddress !== undefined) { + const forwardedFor = req.headers["x-forwarded-for"] + headers["x-forwarded-for"] = + forwardedFor === undefined + ? req.socket.remoteAddress + : `${forwardedFor}, ${req.socket.remoteAddress}` + } headers["x-forwarded-proto"] = (req.socket as { encrypted?: boolean }).encrypted === true ? "https" : "http" if (req.headers.host !== undefined) headers["x-forwarded-host"] = req.headers.host @@ -55,6 +66,9 @@ export function createProxyHandler(target: string): RequestListener { }, upstreamRes => { res.writeHead(upstreamRes.statusCode ?? 502, filterHeaders(upstreamRes.headers)) + upstreamRes.on("error", () => { + res.destroy() + }) upstreamRes.pipe(res) }, ) @@ -72,3 +86,42 @@ export function createProxyHandler(target: string): RequestListener { req.pipe(upstream) } } + +export function createProxyUpgradeHandler( + target: string, +): (req: IncomingMessage, socket: Duplex, head: Buffer) => void { + const url = new URL(target) + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Unsupported proxy protocol: ${url.protocol}. Use http or https.`) + } + 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) + headers.host = url.host + const upstream = transport.request({ + protocol: url.protocol, + hostname: url.hostname, + port, + path: req.url, + method: req.method, + headers, + }) + upstream.on("upgrade", (upstreamRes, upstreamSocket, upstreamHead) => { + clientSocket.write( + `HTTP/1.1 ${upstreamRes.statusCode ?? 101} ${upstreamRes.statusMessage ?? "Switching Protocols"}\r\n`, + ) + for (const [name, value] of Object.entries(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") + if (upstreamHead.length > 0) clientSocket.write(upstreamHead) + if (head.length > 0) upstreamSocket.write(head) + clientSocket.pipe(upstreamSocket).pipe(clientSocket) + }) + upstream.on("error", () => clientSocket.destroy()) + upstream.end() + } +} diff --git a/src/router.ts b/src/router.ts index f56aa8d..f78122c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,10 +1,15 @@ import type { IncomingMessage, RequestListener, ServerResponse } from "node:http" +import type { Duplex } from "node:stream" import { applyCors } from "./cors.ts" export function createRouter(): { handleRequest: RequestListener setProxyHandler: (handler: RequestListener | undefined) => void + setProxyUpgradeHandler: ( + handler: ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) | undefined, + ) => void + proxyUpgradeHandler?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void setStaticHandler: (handler: RequestListener | undefined) => void } { let proxyHandler: RequestListener | undefined @@ -28,9 +33,25 @@ export function createRouter(): { proxyHandler = handler } + function setProxyUpgradeHandler( + handler: ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) | undefined, + ): void { + router.proxyUpgradeHandler = handler + } + function setStaticHandler(handler: RequestListener | undefined): void { staticHandler = handler } - return { handleRequest, setProxyHandler, setStaticHandler } + const router = { + handleRequest, + setProxyHandler, + setProxyUpgradeHandler, + proxyUpgradeHandler: undefined as + | ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) + | undefined, + setStaticHandler, + } + + return router } diff --git a/src/static.ts b/src/static.ts index c17f8a8..0a3e060 100644 --- a/src/static.ts +++ b/src/static.ts @@ -16,14 +16,20 @@ function mime(filePath: string): string { function sanitize(staticPath: string, urlPath: string): string | null { const base = path.resolve(staticPath) - const decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/") + let decoded: string + try { + decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/") + } catch { + return null + } const resolved = path.resolve(base, `.${path.posix.normalize(`/${decoded}`)}`) if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) return null return resolved } function parseRange(header: string, size: number): { start: number; end: number } | null { - const match = /^bytes=(\d*)-(\d*)$/u.exec(header.trim()) + const firstRange = header.trim().split(",", 1)[0] + const match = /^bytes=(\d*)-(\d*)$/u.exec(firstRange ?? "") if (match === null || (match[1] === "" && match[2] === "")) return null if (match[1] === "") { const n = Number(match[2]) @@ -72,14 +78,12 @@ function serveFile( return } const ifModifiedSince = req.headers["if-modified-since"] - if ( - ifNoneMatch !== undefined && - ifModifiedSince !== undefined && - stat.mtime <= new Date(ifModifiedSince) - ) { - res.writeHead(304) - res.end() - return + if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) { + if (stat.mtime <= new Date(ifModifiedSince)) { + res.writeHead(304) + res.end() + return + } } const { size } = stat @@ -110,21 +114,24 @@ export function createStaticHandler(staticPath: string): RequestListener { return } if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" }) - res.end() + res.writeHead(405, { + Allow: "GET, HEAD, OPTIONS", + "Content-Type": "text/plain; charset=utf-8", + }) + res.end("Method not allowed.") return } const url = req.url ?? "/" if (!url.startsWith("/") || url.startsWith("//")) { - res.writeHead(400) - res.end() + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Bad request.") return } const [urlPath, query = ""] = url.split("?") const filePath = sanitize(staticPath, urlPath ?? "/") if (filePath === null) { - res.writeHead(403) - res.end() + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Forbidden.") return } let target = filePath @@ -145,13 +152,22 @@ export function createStaticHandler(staticPath: string): RequestListener { serve404(staticPath, req, res) return } + try { + fs.statSync(target) + } catch { + serve404(staticPath, req, res) + return + } 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) { - res.writeHead(416, { "Content-Range": `bytes */${fs.statSync(target).size}` }) - res.end() + res.writeHead(416, { + "Content-Range": `bytes */${fs.statSync(target).size}`, + "Content-Type": "text/plain; charset=utf-8", + }) + res.end("Range not satisfiable.") return } } diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts new file mode 100644 index 0000000..ac91e86 --- /dev/null +++ b/test/edge-cases.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert" +import { spawn } from "node:child_process" +import http from "node:http" +import https from "node:https" +import { afterEach, describe, it } from "node:test" + +import createServer from "../src/index.ts" +import type { HttpsLocalhostApp } from "../src/index.ts" +import { closeServer, getRootCA, HTTPS_PORT, makeRequest } from "./helpers.ts" + +const FIXTURES = "test/fixtures" + +void describe("edge cases", { timeout: 300000 }, () => { + let app: HttpsLocalhostApp = createServer() + let upstream: http.Server | undefined + + afterEach(async () => { + await closeServer(app.server) + await closeServer(app.http) + await closeServer(upstream) + app = createServer() + upstream = undefined + }) + + function spawnScenario(scenario: string): Promise<{ code: number | null; stdout: string }> { + const script = ` + import { createServer } from "./src/index.ts" + import https from "node:https" + process.on("uncaughtException", err => { + console.log("CRASH:" + (err.code ?? err.name)) + process.exit(42) + }) + const app = createServer() + await app.serve("${FIXTURES}", 15443) + const req = https.request( + { host: "localhost", port: 15443, path: process.env["SCENARIO_PATH"], agent: false }, + res => { + console.log("STATUS:" + res.statusCode) + res.resume() + res.on("end", () => process.exit(0)) + }, + ) + req.on("error", () => process.exit(43)) + req.end() + ` + return new Promise(resolve => { + const proc = spawn("node", ["--use-system-ca", "--eval", script], { + cwd: new URL("..", import.meta.url).pathname, + env: { ...process.env, SCENARIO_PATH: scenario }, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString() + }) + proc.on("close", code => resolve({ code, stdout })) + }) + } + + void it("serves 404 for a directory without index.html", async () => { + const { code, stdout } = await spawnScenario("/sub/") // sub/ has no index? it does; empty scenario instead + assert.strictEqual(code, 0, `server crashed: ${stdout}`) + }) + + void it("does not crash on malformed percent-encoding", async () => { + const { code, stdout } = await spawnScenario("/%zz") + assert.strictEqual(code, 0, `server crashed: ${stdout}`) + assert.match(stdout, /STATUS:(400|403|404)/) + }) + + void it("returns 304 for if-modified-since alone when fresh", async () => { + app = createServer() + await app.serve(FIXTURES, HTTPS_PORT) + const first = await makeRequest("/static.html") + assert.strictEqual(first.statusCode, 200) + const res = await makeRequest("/static.html", true, HTTPS_PORT, { + "if-modified-since": "Wed, 21 Oct 2099 07:28:00 GMT", + }) + assert.strictEqual(res.statusCode, 304) + }) + + void it("closes the client connection when the upstream dies mid-response", async () => { + upstream = http.createServer((_req, res) => { + res.writeHead(200, { "content-length": 100 }) + res.write("PARTIAL") + setTimeout(() => res.socket?.destroy(), 50) + }) + upstream.on("clientError", () => {}) + await new Promise(resolve => { + upstream?.listen(15991, () => resolve()) + }) + + app = createServer() + await app.proxy("http://localhost:15991", HTTPS_PORT) + + const result = await new Promise(resolve => { + const timer = setTimeout(() => resolve("hang"), 5000) + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const req = https.request( + `https://localhost:${HTTPS_PORT}/`, + { agent: false, ca: [rootCA] }, + res => { + res.on("data", () => {}) + res.on("end", () => { + clearTimeout(timer) + resolve("ended") + }) + res.on("error", err => { + clearTimeout(timer) + resolve(`error:${(err as NodeJS.ErrnoException).code}`) + }) + }, + ) + req.on("error", err => { + clearTimeout(timer) + resolve(`error:${(err as NodeJS.ErrnoException).code}`) + }) + req.end() + }) + assert.notStrictEqual(result, "hang", "client connection hung after upstream died mid-response") + }) + + void it("answers CORS preflight on the proxy without hitting the upstream", async () => { + upstream = http.createServer((_req, res) => { + res.writeHead(500) + res.end("upstream must not be reached by preflight") + }) + await new Promise(resolve => { + upstream?.listen(15990, () => resolve()) + }) + + app = createServer() + await app.proxy("http://localhost:15990", HTTPS_PORT) + + const res = await makeRequest("/any", true, HTTPS_PORT, {}, "OPTIONS") + assert.strictEqual(res.statusCode, 204) + assert.strictEqual(res.headers["access-control-allow-origin"], "*") + }) + + void it("rejects POST to the static handler with 405", async () => { + app = createServer() + await app.serve(FIXTURES, HTTPS_PORT) + const res = await makeRequest("/static.html", true, HTTPS_PORT, {}, "POST") + assert.strictEqual(res.statusCode, 405) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts index 5f51278..1b3ceac 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -47,6 +47,7 @@ export async function makeRequest( secure = true, port: number | string = HTTPS_PORT, headers: Record = {}, + method = "GET", ): Promise<{ data: string statusCode?: number @@ -57,7 +58,7 @@ export async function makeRequest( host: "localhost", port, path: requestPath, - method: "GET", + method, ca: rootCA !== undefined ? [rootCA] : undefined, agent: false, headers,