From 51140c6f2d9d4c7e9d7f7a7b29f95b84fb6889b3 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 13:03:18 -0400 Subject: [PATCH 1/3] fix: require explicit public network binds Closes #6 --- README.md | 2 ++ src/bind.ts | 12 ++++++++++++ src/contracts.ts | 1 + src/listen.ts | 6 ++++-- src/serve.ts | 7 ++++--- tests/node.test.ts | 14 ++++++++++++++ 6 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 src/bind.ts 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..77c8864 --- /dev/null +++ b/src/bind.ts @@ -0,0 +1,12 @@ +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; +} 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..ef6c7f1 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 { 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 }); } diff --git a/tests/node.test.ts b/tests/node.test.ts index b124c1b..d4ffc88 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -24,6 +24,20 @@ 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(); + expect(typeof localAddress === "object" && 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(); + expect(typeof publicAddress === "object" && publicAddress?.address).toBe("0.0.0.0"); + await new Promise((resolve) => publicServer.close(() => resolve())); + }); + it("should apply native timeout options", async () => { const server = await listen( { fetch: async () => new Response() }, From cca384c2ce33c097d4f2e19a38cba9e6eacda821 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 13:09:22 -0400 Subject: [PATCH 2/3] fix: format loopback IPv6 service URLs --- src/bind.ts | 4 ++++ src/serve.ts | 4 ++-- tests/node.test.ts | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/bind.ts b/src/bind.ts index 77c8864..27101da 100644 --- a/src/bind.ts +++ b/src/bind.ts @@ -10,3 +10,7 @@ export function resolveBindHost(options: Pick { const app = { fetch: async () => new Response() }; const local = await listen(app); const localAddress = local.address(); - expect(typeof localAddress === "object" && localAddress?.address).toBe("127.0.0.1"); + 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(); - expect(typeof publicAddress === "object" && publicAddress?.address).toBe("0.0.0.0"); + 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 () => { From 87b88df628532a27bd0241e7da7a1a5563aac027 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 13:11:33 -0400 Subject: [PATCH 3/3] style: format bind regression test --- tests/node.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/node.test.ts b/tests/node.test.ts index 09c85e1..e4c9e0d 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -36,7 +36,8 @@ describe("Node adapter", () => { 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"); + 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()));