diff --git a/README.md b/README.md index 3be0c26..babf543 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ await running.close(); ``` `serve` handles static assets and closes both the HTTP server and the application during shutdown. +Both `listen` and `serve` bind to `127.0.0.1` by default. A non-loopback +`host` also requires `allowPublicBind: true` so public exposure is explicit. ## MCP over stdio diff --git a/src/bind.ts b/src/bind.ts new file mode 100644 index 0000000..27101da --- /dev/null +++ b/src/bind.ts @@ -0,0 +1,16 @@ +import type { ListenOptions } from "./contracts.js"; + +export function resolveBindHost(options: Pick): string { + const host = options.host ?? "127.0.0.1"; + const loopback = host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host); + if (!loopback && options.allowPublicBind !== true) { + throw new TypeError( + `Refusing to bind non-loopback host ${host} without allowPublicBind: true.`, + ); + } + return host; +} + +export function formatHostForUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} diff --git a/src/contracts.ts b/src/contracts.ts index 42950d6..b45f112 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -15,6 +15,7 @@ export interface NodeHandlerOptions { export interface ListenOptions { port?: number; host?: string; + allowPublicBind?: boolean; backlog?: number; signal?: AbortSignal; requestTimeout?: number; diff --git a/src/listen.ts b/src/listen.ts index b8668c4..be0774b 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -2,6 +2,7 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import type { ServerApp } from "@askrjs/server"; import type { ListenOptions } from "./contracts.js"; +import { resolveBindHost } from "./bind.js"; import { createNodeHandler } from "./handler.js"; import { installWebSockets } from "./websocket.js"; @@ -10,8 +11,9 @@ export type ListeningServer = Server & { }; export function listen(app: ServerApp, options: ListenOptions = {}): Promise { + const host = resolveBindHost(options); const handlerOptions = { - allowedHosts: [options.host ?? "127.0.0.1", "localhost"], + allowedHosts: [host, "localhost"], }; const server = createServer(createNodeHandler(app, handlerOptions)); if (options.requestTimeout !== undefined) server.requestTimeout = options.requestTimeout; @@ -41,7 +43,7 @@ export function listen(app: ServerApp, options: ListenOptions = {}): Promise { + server.listen(options.port ?? 0, host, options.backlog, () => { server.off("error", onError); resolve(server as ListeningServer); }); diff --git a/src/serve.ts b/src/serve.ts index e29f5fe..2602dd2 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import { extname, resolve, sep } from "node:path"; 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 { installWebSockets } from "./websocket.js"; @@ -36,9 +37,10 @@ export async function serve( app: ServerApp & { close?: () => void | Promise }, options: ServeOptions = {}, ): Promise { + const host = resolveBindHost(options); const root = options.assets ? await realpath(resolve(options.assets.root)) : undefined; const handlerOptions = { - allowedHosts: [options.host ?? "127.0.0.1", "localhost"], + allowedHosts: [host, "localhost"], }; const applicationHandler = createNodeHandler( { @@ -152,7 +154,7 @@ export async function serve( await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); - server.listen(options.port ?? 0, options.host ?? "127.0.0.1", options.backlog, () => { + server.listen(options.port ?? 0, host, options.backlog, () => { server.removeListener("error", rejectListen); resolveListen(); }); @@ -165,6 +167,5 @@ export async function serve( await close(); throw new Error("serve requires a TCP address."); } - const host = options.host ?? "127.0.0.1"; - return Object.freeze({ server, url: `http://${host}:${address.port}`, close }); + return Object.freeze({ server, url: `http://${formatHostForUrl(host)}:${address.port}`, close }); } diff --git a/tests/node.test.ts b/tests/node.test.ts index b124c1b..e4c9e0d 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { createRouter, createServerApp } from "@askrjs/server"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; +import { formatHostForUrl } from "../src/bind.js"; import { createNodeHandler, listen, serve } from "../src/index.js"; import { writeNodeResponse } from "../src/response.js"; @@ -24,6 +25,26 @@ async function withServer( } describe("Node adapter", () => { + it("should bind to loopback by default and require public bind opt-in", async () => { + const app = { fetch: async () => new Response() }; + const local = await listen(app); + const localAddress = local.address(); + if (!localAddress || typeof localAddress === "string") throw new Error("Expected TCP address"); + expect(localAddress.address).toBe("127.0.0.1"); + await new Promise((resolve) => local.close(() => resolve())); + + expect(() => listen(app, { host: "0.0.0.0" })).toThrow("without allowPublicBind: true"); + const publicServer = await listen(app, { host: "0.0.0.0", allowPublicBind: true }); + const publicAddress = publicServer.address(); + if (!publicAddress || typeof publicAddress === "string") + throw new Error("Expected TCP address"); + expect(publicAddress.address).toBe("0.0.0.0"); + await new Promise((resolve) => publicServer.close(() => resolve())); + + expect(formatHostForUrl("::1")).toBe("[::1]"); + expect(formatHostForUrl("127.0.0.1")).toBe("127.0.0.1"); + }); + it("should apply native timeout options", async () => { const server = await listen( { fetch: async () => new Response() },