diff --git a/README.md b/README.md index 567a9e5..4fb9d35 100644 --- a/README.md +++ b/README.md @@ -579,11 +579,27 @@ An instance is a name. Its machine config and all of its state live under `~/.fo | `webPort` | no | first free port from 8090, chosen by `add` | | `minGraphqlPoints` | no | 500 | | `notify` | no | none (§7) | +| `webAuth` | 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 `--model`, so a session never inherits whatever the interactive `claude` default happens to be. +### Reaching the page from outside this Mac + +The page listens on `127.0.0.1` and, by default, refuses any request whose `Host` is not +localhost. To reach it through a tunnel, set HTTP Basic credentials: + +```json +"webAuth": { "user": "me", "password": "at least eight characters" } +``` + +With `webAuth` set, every route (the page itself included) demands those credentials, the `Host` +check is dropped, and `foreman stop`/`go`/`model`/`cap` send them too. Then +`ngrok http 8090` (or any tunnel that terminates TLS) exposes the page; the browser prompts once. +The password is a credential like the Slack webhook: it is never logged or served. 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/docs/2026-09-18-foreman-extraction-design.md b/docs/2026-09-18-foreman-extraction-design.md index d2434b9..2c0f97c 100644 --- a/docs/2026-09-18-foreman-extraction-design.md +++ b/docs/2026-09-18-foreman-extraction-design.md @@ -97,6 +97,7 @@ Today's schema minus `slackUser`: | `webPort` | no | first free port from 8090, chosen by `add` | | `minGraphqlPoints` | no | 500 | | `notify` | no | none | +| `webAuth` | no | none: `{ user, password }` for HTTP Basic; set, every route demands it and the localhost-only Host check is dropped so a tunnel can reach the page | `webPort` must be unique per instance on a Mac; `foreman add` refuses a port another instance already has. diff --git a/src/cli/ctl.ts b/src/cli/ctl.ts index 8ed8931..690d264 100644 --- a/src/cli/ctl.ts +++ b/src/cli/ctl.ts @@ -14,6 +14,7 @@ import { loadRepoConfigSafe } from "../repo-config.ts"; import { readSessions } from "../sessions.ts"; import { readState } from "../state-file.ts"; import { describeStatus, formatStatus } from "../status.ts"; +import { basicAuthorization } from "../web.ts"; import { launchdLabel } from "./launchd.ts"; export type CtlCommand = "status" | "next" | "stop" | "abort" | "go" | "model" | "cap"; @@ -108,6 +109,7 @@ export async function runCtl( async function postToDaemon(path: string, body: unknown, fallback: string): Promise { const r = await fetch(`http://127.0.0.1:${cfg.webPort}/api/${path}`, { method: "POST", + headers: cfg.webAuth ? { authorization: basicAuthorization(cfg.webAuth) } : {}, body: JSON.stringify(body), }); const j = (await r.json()) as { ok: boolean; message?: string; error?: string }; diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 41a692e..784fd38 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -179,6 +179,7 @@ export async function runDaemon( const launchdIsInstalled = await launchdInstalled(launchdLabel(instance.name)); const web = await startWebServer({ port: cfg.webPort, + auth: cfg.webAuth, status: () => describeStatus({ state: store.get(), diff --git a/src/config.test.ts b/src/config.test.ts index 20304b1..096ffb2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -82,3 +82,21 @@ describe("expandHome", () => { it("expands ~/", () => expect(expandHome("~/x")).not.toContain("~")); it("leaves absolute paths", () => expect(expandHome("/a/b")).toBe("/a/b")); }); + +describe("webAuth", () => { + it("is absent by default and accepted when both user and password are set", () => { + expect(parseConfig(JSON.stringify(base)).webAuth).toBeUndefined(); + const cfg = parseConfig( + JSON.stringify({ ...base, webAuth: { user: "me", password: "correct horse" } }), + ); + expect(cfg.webAuth).toEqual({ user: "me", password: "correct horse" }); + }); + it("rejects a short password and a missing user", () => { + expect(() => + parseConfig(JSON.stringify({ ...base, webAuth: { user: "me", password: "short" } })), + ).toThrow(); + expect(() => + parseConfig(JSON.stringify({ ...base, webAuth: { password: "correct horse" } })), + ).toThrow(); + }); +}); diff --git a/src/config.ts b/src/config.ts index 87eb5eb..966abd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,11 @@ import { join } from "node:path"; import { z } from "zod"; import { NotifyConfigSchema } from "./notify.ts"; +export const WebAuthSchema = z + .object({ user: z.string().min(1), password: z.string().min(8) }) + .strict(); +export type WebAuth = z.infer; + export const ConfigSchema = z.object({ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/), /** The board's number. Absent until `foreman init` creates it, which is what fills this in. */ @@ -32,6 +37,12 @@ export const ConfigSchema = z.object({ * nothing on the page), so keep `foreman.json` out of the repo as it already is. */ notify: NotifyConfigSchema.optional(), + /** + * HTTP Basic credentials for the page. Absent, the page answers localhost only; set, it answers + * any Host that presents these, so a tunnel (ngrok and the like) can reach it. The password is + * a credential like `slackWebhookUrl`: never logged, never served. + */ + webAuth: WebAuthSchema.optional(), }); export type ForemanConfig = Omit, "workDir"> & { owner: string; diff --git a/src/web.test.ts b/src/web.test.ts index 92eb666..47a3649 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -7,7 +7,7 @@ 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 { createWebServer, isLocalHost } from "./web.ts"; +import { basicAuthorization, createWebServer, isLocalHost } from "./web.ts"; const report: StatusReport = { host: "mac-a", @@ -280,6 +280,112 @@ describe("web server", () => { }); }); +describe("web server with webAuth", () => { + const auth = { user: "me", password: "correct horse" }; + const acts: string[] = []; + const server = createWebServer({ + port: 0, + auth, + status: () => report, + next: async () => nextReport, + act: async (cmd) => { + acts.push(cmd); + return `did ${cmd}`; + }, + feed: () => [], + owner: async () => "", + ownerItems: () => [], + needsYouItems: () => [], + unblock: async () => "", + setModel: async () => "", + setCap: async () => "", + refresh: async () => "", + phaseTasks: () => [], + setTaskModel: async () => "", + modelChoices: () => [], + html: "Foreman", + }); + let base = ""; + beforeAll(async () => { + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(() => server.close()); + + /** node:http so the Host header can be set; returns status, body and the challenge header. */ + const raw = (path: string, headers: Record, method = "GET") => + new Promise<{ status: number; body: string; challenge: string | undefined }>((resolve) => { + const req = http.request( + { host: "127.0.0.1", port: (server.address() as AddressInfo).port, path, method, headers }, + (res) => { + let body = ""; + res.on("data", (c) => { + body += c; + }); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + body, + challenge: res.headers["www-authenticate"], + }), + ); + }, + ); + req.end(); + }); + + it("challenges a request with no credentials, even for the page itself", async () => { + const page = await fetch(`${base}/`); + expect(page.status).toBe(401); + expect(page.headers.get("www-authenticate")).toBe('Basic realm="foreman"'); + expect(await page.json()).toEqual({ ok: false, error: "unauthorized" }); + const status = await fetch(`${base}/api/status`); + expect(status.status).toBe(401); + const post = await fetch(`${base}/api/stop`, { method: "POST" }); + expect(post.status).toBe(401); + expect(acts).toEqual([]); + }); + it("refuses a wrong password, a wrong user and a non-Basic scheme", async () => { + const wrong = Buffer.from("me:wrong password").toString("base64"); + expect( + (await fetch(`${base}/api/status`, { headers: { authorization: `Basic ${wrong}` } })).status, + ).toBe(401); + const user = Buffer.from("you:correct horse").toString("base64"); + expect( + (await fetch(`${base}/api/status`, { headers: { authorization: `Basic ${user}` } })).status, + ).toBe(401); + expect( + (await fetch(`${base}/api/status`, { headers: { authorization: "Bearer x" } })).status, + ).toBe(401); + }); + it("accepts the right credentials from any Host, so a tunnel can reach the page", async () => { + const headers = { host: "abc.ngrok.app", authorization: basicAuthorization(auth) }; + const status = await raw("/api/status", headers); + expect(status.status).toBe(200); + expect(JSON.parse(status.body)).toEqual(report); + const page = await raw("/", headers); + expect(page.status).toBe(200); + expect(page.body).toBe("Foreman"); + const post = await raw("/api/stop", headers, "POST"); + expect(post.status).toBe(200); + expect(acts).toEqual(["stop"]); + }); + it("still accepts the right credentials on localhost", async () => { + const r = await fetch(`${base}/api/status`, { + headers: { authorization: basicAuthorization(auth) }, + }); + expect(r.status).toBe(200); + }); +}); + +describe("basicAuthorization", () => { + it("encodes user:password the way a browser does", () => { + expect(basicAuthorization({ user: "me", password: "correct horse" })).toBe( + `Basic ${Buffer.from("me:correct horse").toString("base64")}`, + ); + }); +}); + describe("isLocalHost", () => { it("accepts loopback names with the right port only", () => { expect(isLocalHost("127.0.0.1:8090", 8090)).toBe(true); diff --git a/src/web.ts b/src/web.ts index bb0e9f6..8414642 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,8 +1,10 @@ +import { timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { join } from "node:path"; import { z } from "zod"; +import type { WebAuth } from "./config.ts"; import { FEED_LIMIT_DEFAULT, type FeedEntry, isSessionId } from "./feed.ts"; import { log } from "./log.ts"; import type { NextReport } from "./next.ts"; @@ -24,6 +26,11 @@ export type WebCommand = "stop" | "abort" | "go"; export interface WebDeps { port: number; + /** + * HTTP Basic credentials. Set, every route demands them and the Host check is dropped, so a + * tunnel can reach the page; absent, the page stays localhost-only (spec §7). + */ + auth?: WebAuth; status: () => StatusReport; next: () => Promise; act: (cmd: WebCommand) => Promise; @@ -57,6 +64,19 @@ export function isLocalHost(hostHeader: string | undefined, port: number): boole return hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}`; } +/** The `Authorization` value a client sends for these credentials; what `ctl` uses too. */ +export function basicAuthorization(auth: WebAuth): string { + return `Basic ${Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}`; +} + +/** Constant-time on the encoded header, so neither a length nor a prefix leaks by timing. */ +function authorized(header: string | undefined, auth: WebAuth): boolean { + if (!header) return false; + const want = Buffer.from(basicAuthorization(auth)); + const got = Buffer.from(header); + return want.length === got.length && timingSafeEqual(want, got); +} + const OwnerRequestSchema = z.object({ epic: z.number().int().positive(), action: OwnerActionSchema, @@ -110,9 +130,17 @@ export function createWebServer(d: WebDeps): http.Server { const u = new URL(req.url ?? "/", "http://localhost"); const url = u.pathname; try { - // Spec §7: every route is localhost-only, not just the POST actions. - if (!isLocalHost(req.headers.host, bound)) + if (d.auth) { + // Credentials replace the Host check: a rebinding page never carries them, because the + // browser caches Basic credentials per origin and the attacker's origin is not this one. + if (!authorized(req.headers.authorization, d.auth)) { + res.setHeader("www-authenticate", 'Basic realm="foreman"'); + return json(res, 401, { ok: false, error: "unauthorized" }); + } + } else if (!isLocalHost(req.headers.host, bound)) { + // Spec §7: every route is localhost-only, not just the POST actions. return json(res, 403, { ok: false, error: "localhost only" }); + } if (req.method === "GET" && url === "/") return send(res, 200, html, "text/html; charset=utf-8"); if (req.method === "GET" && url === "/api/status") return json(res, 200, d.status());