From 53ba085efa43b5e96e617abe26f0d19fa90d8311 Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Tue, 22 Sep 2026 12:08:01 -0700 Subject: [PATCH] feat(web): opt-in webHosts to reach the page over a private network webHosts lists hostnames and IP literals the page answers to without webAuth. Every entry is an accepted Host at webPort (IPv6 in brackets); IP literals are also listened on next to 127.0.0.1. Hostnames are never resolved or bound. An extra address that cannot be bound is a warning. Absent, the page stays localhost-only exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WhVzVMS6mftCFV735rT41b --- README.md | 22 ++++++++ src/cli/daemon.ts | 1 + src/config.test.ts | 12 +++++ src/config.ts | 7 +++ src/web.test.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++- src/web.ts | 65 +++++++++++++++++++---- 6 files changed, 222 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9b5a9d3..2286280 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,7 @@ An instance is a name. Its machine config and all of its state live under `~/.fo | `minGraphqlPoints` | no | 500 | | `notify` | no | none (§7) | | `webAuth` | no | none: the page is localhost-only | +| `webHosts` | no | none: the page is localhost-only | `workDir` absent means worktrees live at `/.worktrees/`, so a checkout is self-contained; add `.worktrees/` to the repository's `.gitignore`. `model` is always passed as @@ -598,6 +599,27 @@ check is dropped, and `foreman stop`/`go`/`model`/`cap` send them too. Then The password is a credential like the Slack webhook: it is never logged or served. Restart the daemon after changing it. +To reach the page over a private network without credentials, list the names this Mac answers to +on that network: + +```json +"webHosts": ["mini", "100.84.252.56"] +``` + +Each entry is a hostname or an IP literal. The page accepts every entry as a `Host` at +`webPort`, so `http://mini:8090` and `http://100.84.252.56:8090` both load it; an IPv6 literal is +written bare in the list and sent in brackets by the browser (`http://[fd7a::1]:8090`). IP literals +are also listened on, next to `127.0.0.1`, which is always bound. Hostnames are only matched against +`Host`; the daemon never resolves or binds them, so list the IP the name resolves to as well. An +address this Mac does not hold when the daemon starts (the network interface is down, say) logs a +warning and the page stays up on the others; restart the daemon once the interface is back. Each +bound address logs a `web page listening` line with its URL. + +Anyone who can reach a listed address gets unauthenticated control of the page: stop, go, model, +cap, unblock and the owner actions. List only addresses on a private network you trust. With +`webAuth` also set, credentials replace the `Host` check as above, and `webHosts` only adds the +listen addresses. Restart the daemon after changing it. + ### Which instance a command means Every command that needs an instance resolves it in this order and stops at the first hit: diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 4837134..e8507ff 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -180,6 +180,7 @@ export async function runDaemon( const web = await startWebServer({ port: cfg.webPort, auth: cfg.webAuth, + hosts: cfg.webHosts, status: () => describeStatus({ state: store.get(), diff --git a/src/config.test.ts b/src/config.test.ts index 096ffb2..6e029c6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -100,3 +100,15 @@ describe("webAuth", () => { ).toThrow(); }); }); + +describe("webHosts", () => { + it("is absent by default and accepts hostnames and IP literals", () => { + expect(parseConfig(JSON.stringify(base)).webHosts).toBeUndefined(); + const cfg = parseConfig(JSON.stringify({ ...base, webHosts: ["mini", "100.84.252.56"] })); + expect(cfg.webHosts).toEqual(["mini", "100.84.252.56"]); + }); + it("rejects an empty entry and a non-list", () => { + expect(() => parseConfig(JSON.stringify({ ...base, webHosts: [""] }))).toThrow(); + expect(() => parseConfig(JSON.stringify({ ...base, webHosts: "mini" }))).toThrow(); + }); +}); diff --git a/src/config.ts b/src/config.ts index 87c4e7e..3e6e5e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,6 +43,13 @@ export const ConfigSchema = z.object({ * a credential like `slackWebhookUrl`: never logged, never served. */ webAuth: WebAuthSchema.optional(), + /** + * Extra names the page answers to, each a hostname or an IP literal, so a private network can + * reach it without `webAuth`. Every entry is an accepted `Host` at `webPort`; IP literals are + * also listened on, next to `127.0.0.1`. Hostnames are never resolved or bound. Absent, the page + * is localhost-only. Anyone who can reach a listed address controls the page unauthenticated. + */ + webHosts: z.array(z.string().min(1)).optional(), }); export type ForemanConfig = Omit, "workDir"> & { owner: string; diff --git a/src/web.test.ts b/src/web.test.ts index 8f00dd8..38ef7f1 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -2,12 +2,19 @@ import { readFileSync } from "node:fs"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { FeedEntry } from "./feed.ts"; import type { NextReport } from "./next.ts"; import { CAP_CHOICES, MODEL_CHOICES } from "./state-file.ts"; import type { StatusReport } from "./status.ts"; -import { basicAuthorization, createWebServer, isLocalHost } from "./web.ts"; +import { + basicAuthorization, + createWebServer, + isLocalHost, + listenAddresses, + startWebServer, + type WebDeps, +} from "./web.ts"; const report: StatusReport = { host: "mac-a", @@ -394,6 +401,121 @@ describe("isLocalHost", () => { expect(isLocalHost("evil.example:8090", 8090)).toBe(false); expect(isLocalHost(undefined, 8090)).toBe(false); }); + it("accepts nothing beyond loopback when webHosts is absent or empty", () => { + expect(isLocalHost("mini:8090", 8090)).toBe(false); + expect(isLocalHost("mini:8090", 8090, [])).toBe(false); + }); + it("accepts each webHosts entry at the page's port, and nothing else", () => { + const hosts = ["mini", "100.84.252.56", "fd7a:115c::1"]; + expect(isLocalHost("mini:8090", 8090, hosts)).toBe(true); + expect(isLocalHost("MINI:8090", 8090, hosts)).toBe(true); + expect(isLocalHost("100.84.252.56:8090", 8090, hosts)).toBe(true); + expect(isLocalHost("127.0.0.1:8090", 8090, hosts)).toBe(true); + expect(isLocalHost("mini:8091", 8090, hosts)).toBe(false); + expect(isLocalHost("mini", 8090, hosts)).toBe(false); + expect(isLocalHost("evil.example:8090", 8090, hosts)).toBe(false); + expect(isLocalHost("mini.evil.example:8090", 8090, hosts)).toBe(false); + }); + it("expects an IPv6 entry in brackets, the way a browser sends it", () => { + const hosts = ["fd7a:115c::1"]; + expect(isLocalHost("[fd7a:115c::1]:8090", 8090, hosts)).toBe(true); + expect(isLocalHost("fd7a:115c::1:8090", 8090, hosts)).toBe(false); + expect(isLocalHost("[fd7a:115c::1]:8091", 8090, hosts)).toBe(false); + }); +}); + +describe("listenAddresses", () => { + it("is loopback alone when webHosts is absent", () => { + expect(listenAddresses(undefined)).toEqual(["127.0.0.1"]); + expect(listenAddresses([])).toEqual(["127.0.0.1"]); + }); + it("adds the IP literals, never the hostnames, and binds each address once", () => { + expect( + listenAddresses(["mini", "100.84.252.56", "fd7a:115c::1", "127.0.0.1", "100.84.252.56"]), + ).toEqual(["127.0.0.1", "100.84.252.56", "fd7a:115c::1"]); + }); +}); + +describe("web server with webHosts", () => { + const deps: WebDeps = { + port: 0, + hosts: ["mini", "::1"], + status: () => report, + next: async () => nextReport, + act: async () => "", + feed: () => [], + owner: async () => "", + ownerItems: () => [], + needsYouItems: () => [], + unblock: async () => "", + setModel: async () => "", + setCap: async () => "", + refresh: async () => "", + phaseTasks: () => [], + setTaskModel: async () => "", + modelChoices: () => [], + html: "Foreman", + }; + + /** node:http so the Host header can be set. */ + const get = (address: string, port: number, host: string) => + new Promise((resolve, reject) => { + const req = http.request( + { host: address, port, path: "/api/status", headers: { host } }, + (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }, + ); + req.on("error", reject); + req.end(); + }); + + it("answers a listed Host and still refuses a foreign one", async () => { + const server = createWebServer(deps); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + try { + expect(await get("127.0.0.1", port, `mini:${port}`)).toBe(200); + expect(await get("127.0.0.1", port, `[::1]:${port}`)).toBe(200); + expect(await get("127.0.0.1", port, `evil.example:${port}`)).toBe(403); + } finally { + server.close(); + } + }); + + it("listens on loopback and on each IP literal, and closes them together", async () => { + const server = await startWebServer(deps); + if (!server) throw new Error("web page not started"); + const port = (server.address() as AddressInfo).port; + try { + expect(await get("127.0.0.1", port, `127.0.0.1:${port}`)).toBe(200); + expect(await get("::1", port, `[::1]:${port}`)).toBe(200); + } finally { + await new Promise((r) => server.close(r)); + } + await expect(get("::1", port, `[::1]:${port}`)).rejects.toThrow(); + }); + + it("warns and keeps loopback when an extra address cannot be bound", async () => { + const lines: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); + // 192.0.2.0/24 is reserved for documentation, so no interface ever holds it. + const server = await startWebServer({ ...deps, hosts: ["192.0.2.1"] }); + spy.mockRestore(); + if (!server) throw new Error("web page not started"); + const port = (server.address() as AddressInfo).port; + try { + expect(await get("127.0.0.1", port, `127.0.0.1:${port}`)).toBe(200); + const warn = lines.map((l) => JSON.parse(l)).find((l) => l.level === "warn"); + expect(warn).toMatchObject({ msg: "web page not started", address: "192.0.2.1" }); + } finally { + server.close(); + } + }); }); describe("web.html", () => { diff --git a/src/web.ts b/src/web.ts index b610a9c..e6a5a16 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,7 +1,7 @@ import { timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import http from "node:http"; -import type { AddressInfo } from "node:net"; +import { type AddressInfo, isIP } from "node:net"; import { join } from "node:path"; import { z } from "zod"; import type { WebAuth } from "./config.ts"; @@ -31,6 +31,11 @@ export interface WebDeps { * tunnel can reach the page; absent, the page stays localhost-only (spec §7). */ auth?: WebAuth; + /** + * Extra names the page answers to (hostnames or IP literals). Each is an accepted Host at the + * page's port; IP literals are also listened on next to 127.0.0.1. Absent, localhost only. + */ + hosts?: string[]; status: () => StatusReport; next: () => Promise; act: (cmd: WebCommand) => Promise; @@ -59,9 +64,31 @@ export interface WebDeps { html?: string; } -export function isLocalHost(hostHeader: string | undefined, port: number): boolean { +/** An entry as a browser writes it in `Host`: an IPv6 literal goes in brackets. */ +function hostForm(entry: string): string { + return isIP(entry) === 6 ? `[${entry}]` : entry; +} + +/** + * Whether `Host` names this page: loopback, or one of `hosts`, at exactly this port. The DNS + * rebinding guard (spec §7): a hostile page's requests carry the hostile page's name. + */ +export function isLocalHost( + hostHeader: string | undefined, + port: number, + hosts: string[] = [], +): boolean { if (!hostHeader) return false; - return hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}`; + const got = hostHeader.toLowerCase(); + return ["127.0.0.1", "localhost", ...hosts].some( + (h) => got === `${hostForm(h).toLowerCase()}:${port}`, + ); +} + +/** Where the page listens: 127.0.0.1 always, then each IP literal in `hosts`, once each. */ +export function listenAddresses(hosts: string[] | undefined): string[] { + const ips = (hosts ?? []).filter((h) => isIP(h) !== 0); + return [...new Set(["127.0.0.1", ...ips])]; } /** The `Authorization` value a client sends for these credentials; what `ctl` uses too. */ @@ -137,7 +164,7 @@ export function createWebServer(d: WebDeps): http.Server { res.setHeader("www-authenticate", 'Basic realm="foreman"'); return json(res, 401, { ok: false, error: "unauthorized" }); } - } else if (!isLocalHost(req.headers.host, bound)) { + } else if (!isLocalHost(req.headers.host, bound, d.hosts)) { // Spec §7: every route is localhost-only, not just the POST actions. return json(res, 403, { ok: false, error: "localhost only" }); } @@ -224,17 +251,37 @@ export function createWebServer(d: WebDeps): http.Server { return server; } -/** Listens on 127.0.0.1:. A busy port is a warning, not a failure. */ -export function startWebServer(d: WebDeps): Promise { +/** Listens on one address; a failure is a warning, not a failure, and resolves null. */ +function listenOn(d: WebDeps, port: number, address: string): Promise { return new Promise((resolve) => { const server = createWebServer(d); server.once("error", (err: NodeJS.ErrnoException) => { - log("warn", "web page not started", { port: d.port, error: err.code ?? err.message }); + log("warn", "web page not started", { port, address, error: err.code ?? err.message }); resolve(null); }); - server.listen(d.port, "127.0.0.1", () => { - log("info", "web page listening", { url: `http://127.0.0.1:${d.port}` }); + server.listen(port, address, () => { + const bound = (server.address() as AddressInfo).port; + log("info", "web page listening", { url: `http://${hostForm(address)}:${bound}` }); resolve(server); }); }); } + +/** + * Listens on 127.0.0.1:, then on each IP literal in `hosts` at the same port. A busy port + * or an address this Mac does not hold (its interface is down) is a warning, not a failure; the + * extra addresses are only tried once loopback is up. Closing the returned server closes them all. + */ +export async function startWebServer(d: WebDeps): Promise { + const [loopback, ...extra] = listenAddresses(d.hosts); + const server = await listenOn(d, d.port, loopback ?? "127.0.0.1"); + if (!server) return null; + const port = (server.address() as AddressInfo).port; + const others = (await Promise.all(extra.map((a) => listenOn(d, port, a)))).filter( + (s): s is http.Server => s !== null, + ); + server.once("close", () => { + for (const s of others) s.close(); + }); + return server; +}