diff --git a/SECURITY.md b/SECURITY.md index d2736258..de1f8f91 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -76,7 +76,7 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat | The operator console process (`pi-dispatch-console serve`) | **Operator, the same trust as the admin extension** | It runs as the deployment's account and writes through the same read-model funnels as the panel. It renders untrusted text (issue titles, and from #641 on run text) but only escaped, after the control-byte scrub, under a strict CSP and behind the operator role; it executes none of it and feeds none of it to a model. It listens on loopback or a 0600 unix socket, never on the receiver ([The operator console](#the-operator-console)) | | The operator's browser | **Operator, while it holds a session** | A signed-in browser acts as the token's person. Every other page that browser opens, including one on another `localhost` port, is **None**: the console refuses its writes by origin, not by trust in the browser | | The console tokens file (`PI_CONSOLE_TOKENS_FILE`) | **Operator, the same trust as the deployment's `.env`** | It says who may sign in. It holds sha256 hashes only, so a copy signs no one in, but whoever can WRITE it can add a person. The console refuses to start on one that is group or world accessible, a symlink or another account's; the pi extension guards it from its own session's `write` and `edit`; doctor fails it inside any path a job container can see (a local trigger's `run.folder` or `run.skillsDir`, a `PI_DISPATCH_RUN_ROOTS` root, `PI_GLOBAL_PI_DIR`) | -| The login-code drop directory (`console-login-codes/` beside the tokens file) | **Operator, the same trust as the tokens file** | `pi-dispatch-console link` writes a one-time code's hash there (directory 0700, file 0600, 60 seconds, consumed by the first `unlink` that wins). Whoever can write it can mint a login, which is why it must be the deployment account's alone | +| The login-code drop directory (`console-login-codes/` beside the tokens file) | **Operator, the same trust as the tokens file** | `pi-dispatch-console link` and, since issue #708, `/dispatch console --link` write a one-time code's hash there (directory 0700, file 0600, 60 seconds, consumed by the first `unlink` that wins; the panel appends its audit row first). Whoever can write it can mint a login, which is why it must be the deployment account's alone, and why the pi extension guards the directory, every path under it, from its own session's `write` and `edit`, as it guards the tokens file | | Receiver, worker, queue, admin extension, operator console | Trusted | They never execute agent-authored content: the admin extension feeds only PII-free, fixed-enum run records to the model; raw container output stays in the overlay viewer. The operator console renders untrusted text as data only: escaped, behind the operator role, and never in a list view | ## What is defended @@ -1045,7 +1045,8 @@ Stated openly rather than discovered later: **The model in that session has a shell too, and the write guard cannot see it.** The admin extension blocks pi's `write` and `edit` tools (a call nested through `ctx.executeTool` or a codemode script included) whose target is the envelope file, `projects.json`, `scoped-limits.json`, `triggers.json`, the settings overlay, the - deployment's `.env` or the deployment pointer (issue #504). The `.env` and the pointer are guarded because they + deployment's `.env`, the deployment pointer (issue #504), the console tokens file (issue #640) or anything in the + console's login-codes directory beside it (issue #708). The `.env` and the pointer are guarded because they NAME the other files: the panel reads `.env` on every call, and a session that could point it at a decoy could unguard the real file, write it, and point it back; the guarded set also never shrinks within a session. With no pointer, the `.env` of the folder pi started in may only add to the set (every guarded file it names, and itself), diff --git a/admin/README.md b/admin/README.md index 172c290f..d039de43 100644 --- a/admin/README.md +++ b/admin/README.md @@ -242,7 +242,10 @@ It can act as well as show: cancel, retry, remove and re-run jobs, answer a park and edit the configuration, each previewed, confirmed and recorded in one audit log that holds the panel's own writes too. Every running job's progress shows in its row and on its run page, and its transcript, redacted, streams live on its run page for operators, and the panel's live tail -(`l`) reads the same transcript for a run on any host. Nothing listens unless you start it. See +(`l`) reads the same transcript for a run on any host. Nothing listens unless you start it. In pi, +`/dispatch console [page] [--link]` prints a page's address, says whether the console is running, makes a one time +sign-in link for an operator token (shown only to you, and recorded in the audit log first), and opens your browser +when the session is local (not over SSH; `--no-open` keeps it closed). See [`docs/console.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/console.md). ## More than one machine diff --git a/admin/src/console-command.mjs b/admin/src/console-command.mjs new file mode 100644 index 00000000..82cc1316 --- /dev/null +++ b/admin/src/console-command.mjs @@ -0,0 +1,237 @@ +/** + * `/dispatch console [page] [--link [name]] [--no-open]` (issue #708): from pi to the operator console in one step. + * It prints the page's address (`consolePageLines`, the rules `/dispatch insights` uses), says whether the console is + * running (the worker's `console-probe.mjs`, the one rule doctor uses too: one `GET /healthz` to this host's loopback or + * the socket and nowhere else), with `--link` mints + * a one-time sign-in code for an operator token, and on a local session opens the browser. + * + * AN OPERATOR-TYPED COMMAND, NEVER A TOOL (DES-ADMIN-VIA-PI-EXTENSION). A sign-in link is a login to the console as an + * operator, so nothing here reaches model context: every word goes to `notify`, which is the operator's alone, and + * nothing calls `send`. A ctx without a UI (print and json mode) is refused before anything is probed or minted, since + * its notice would have nowhere to go. RPC mode HAS a UI (`hasUI` is true there), but its `notify` goes to the RPC + * client, another program and possibly another machine, so the address and the state are shown there and `--link` is + * refused: a sign-in link is for the operator's own terminal (`ctx.mode`, `"tui"`, `"rpc"`, `"json"` or `"print"` at + * the pinned pi). + * + * THE PANEL IS A SECOND MINTER of login codes, beside `pi-dispatch-console link`, and the same trust: the panel runs + * as the deployment account, which can read the tokens file and write the code drop anyway. It differs in one rule, + * the panel's own (`audited-write.mjs`): the audit row is appended BEFORE the code exists, and a code whose row cannot + * be appended is never made. The CLI appends after, for its own reasons (a shell command's change has happened by + * then); the panel's rule is the stricter one, so a link that works is always one the log holds. + * + * THE BROWSER OPENS by default on a local session only (`isLocalBrowserSession`: no SSH variable, a desktop opener or a + * display), and only for a TCP listener at a concrete address: a socket is reached through a tunnel this command cannot + * open, and `` is no address to open. `--no-open` keeps it closed. The URL is printed always, because the + * printed URL is the contract and the browser a convenience (`open-browser.mjs`). With `--link` the opened URL carries + * the code, so the opener is handed a private redirect page's path, never the link (`open-link.mjs`: an opener's argv + * is readable by every local account while it runs). + * + * A LINK IS MADE ONLY WHERE THE GUARD STANDS: the drop the code is written to is the one `guardedFiles()` guards + * (`deps.guarded`), so a session whose deployment the panel cannot see (where nothing is guarded) never mints into a + * directory its own `write` could plant codes in. And only for a console the probe found running: a code expires 60 + * seconds after it is made, and "unknown" is no promise anyone will spend it. + * + * Pi-free: index.ts hands in the deployment's env, paths and the ctx's UI; the tests hand in fakes for each seam. + */ +import { readTokensFile } from "@edgehero/pi-dispatch/console-tokens"; +import { isLocalBrowserSession, openBrowser } from "@edgehero/pi-dispatch/open-browser"; +import { panelActor, panelAudit, writeTicket } from "./audited-write.mjs"; +import { consolePageLines, consolePageUrl } from "./console/link.mjs"; +import { loginCodesDir, mintLoginCode } from "./console/login-codes.mjs"; +import { openLinkViaFile } from "./console/open-link.mjs"; +import { probeConsole } from "@edgehero/pi-dispatch/console-probe"; +import { CONSOLE_START_ADVICE } from "./console/link.mjs"; +import { RUN_ID_RE } from "./read-model.mjs"; + +export const CONSOLE_USAGE = "usage: /dispatch console [runs|fleet|insights|config|parked|audit|] [--link [token name]] [--no-open]"; + +/** The page words the command takes, each the console page of that name. Anything else that is a run id is `run?id=`. */ +export const CONSOLE_PAGE_WORDS = Object.freeze(["runs", "fleet", "insights", "config", "parked", "audit"]); + +/** How to make an operator token, said when `--link` finds none. */ +const TOKEN_ADD_HINT = "add one in the operator's own shell with pi-dispatch-console token add --name --role operator"; + +/** + * The words after `console`: `{ page, link, name, noOpen }` or `{ usage: true }`. The word right after `--link` is the + * token's name unless it is a flag, so a page goes before `--link` (`/dispatch console runs --link alice`). + */ +export function parseConsoleArgs(words) { + let page = null; + let link = false; + let name = null; + let noOpen = false; + for (let i = 0; i < words.length; i += 1) { + const w = words[i]; + if (w === "--link") { + if (link) return { usage: true }; + link = true; + const next = words[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + name = next; + i += 1; + } + } else if (w === "--no-open") { + if (noOpen) return { usage: true }; + noOpen = true; + } else if (w.startsWith("-") || page !== null) { + return { usage: true }; + } else { + page = w; + } + } + return { page, link, name, noOpen }; +} + +/** + * A page word as the console path it opens (no leading `/`, a query allowed): `""` for the overview, `runs`, ..., + * `run?id=` for a run id; null for anything else. The run id charset is the read model's (`RUN_ID_RE`), which the + * console's `/run` page holds an id to, so a path printed here is one the page accepts. + */ +export function consolePagePath(page) { + if (page === null || page === undefined) return ""; + if (CONSOLE_PAGE_WORDS.includes(page)) return page; + if (RUN_ID_RE.test(page)) return `run?id=${page}`; + return null; +} + +/** + * `` a fragment value: the characters that would end it or be read as another part (`%`, `&`, `#`, `+`, a space) + * percent-encoded, the rest as it is, so `/run?id=a.b` stays readable in the printed link. + */ +function fragmentValue(text) { + return text.replace(/[%&#+\s]/g, (c) => encodeURIComponent(c)); +} + +/** The one line a probe answer reads as. */ +export function probeLine(answer) { + if (answer?.state === "running") return "running"; + if (answer?.state === "not-running") return `not running${answer.reason ? ` (${answer.reason})` : ""}: ${CONSOLE_START_ADVICE}`; + return `whether it runs is not known: ${answer?.reason ?? "it was not asked"}`; +} + +/** + * Run the command. `deps`: + * - `hasUI`, `mode` (pi's `ctx.mode`), `notify(text, level)`, `select(title, options)` (pi's `ctx.ui.select`, + * already gated); + * - `env`, the deployment's env (`PI_CONSOLE_LISTEN`, `PI_CONSOLE_BIND_PUBLIC`); `sessionEnv`, this process's env + * (the SSH and display variables); `platform`; + * - `logsDir` (the audit log's), `tokensPath` (the console tokens file, or null where the panel guards none) and + * `guarded(dir)` (is `dir` a directory `guardedFiles()` guards now); + * - seams: `probe(env, opts)` (the worker's `probeConsole`), `request` (its `http.request` seam), `readTokens()`, `audit` (`{ append(row) }`), `actor`, `ticket`, `mint({ dir, name })`, + * `open(url)` (the opener for a URL with no code), `openLink(url)` (for one with a code, `openLinkViaFile`), + * `isLocal({ env, platform })`. + * Returns what it did, for the tests: `{ refused }`, or `{ state, url, opened, minted }`. + */ +export async function consoleCommand(words, deps) { + const notify = deps.notify ?? (() => {}); + if (!deps.hasUI) { + notify("/dispatch console needs pi's interactive UI: its address, its state and any sign-in link are shown to the operator only, never into the conversation", "warning"); + return { refused: "no-ui" }; + } + const args = parseConsoleArgs(words); + const path = args.usage ? null : consolePagePath(args.page); + if (args.usage || path === null) { + notify(CONSOLE_USAGE, "warning"); + return { refused: "usage" }; + } + const env = deps.env ?? {}; + const platform = deps.platform ?? process.platform; + const probe = deps.probe ?? probeConsole; + const at = consolePageUrl(env, path, { platform }); + const answer = await probe(env, deps.request ? { request: deps.request } : {}); + const lines = at.refusal ? consolePageLines(env, path, { platform }) : [consolePageLines(env, path, { platform })[0]]; + lines.push(`the console: ${probeLine(answer)}`); + + let url = at.url && !at.anyHost ? at.url : null; + let minted = false; + if (args.link) { + const refuse = (why) => { + notify([...lines, `no sign-in link was made: ${why}`].join("\n"), "warning"); + return { refused: "link", state: answer.state }; + }; + // `"tui"` exactly: a pi too old to say its mode is not known to be the operator's terminal either. + if (deps.mode !== "tui") return refuse(`pi runs in ${deps.mode ?? "an unknown"} mode here, where a notice goes to another program, not the operator's terminal; run /dispatch console --link in pi's terminal UI, or pi-dispatch-console link in a shell`); + if (at.refusal || at.ephemeral) return refuse("the console's address is not known here, so a link could not name it; run pi-dispatch-console link --name where the console runs"); + if (answer.state !== "running") return refuse(answer.state === "not-running" ? "the console is not running, and a code expires 60 seconds after it is made" : "the console could not be confirmed running, and a code expires 60 seconds after it is made; start it, or run pi-dispatch-console link where it runs"); + // Mint only into a drop the write guard holds, so this session's own `write` could not have planted a code there. + const codesDir = typeof deps.tokensPath === "string" && deps.tokensPath !== "" ? loginCodesDir(deps.tokensPath) : null; + if (codesDir === null || typeof deps.guarded !== "function" || !deps.guarded(codesDir)) { + return refuse("the panel does not see this deployment's tokens file, so it guards no login-code directory to mint into; run /dispatch setup (a deployment pointer), or start pi in the deployment folder, or use pi-dispatch-console link in a shell"); + } + const read = (deps.readTokens ?? (() => readTokensFile({ path: deps.tokensPath })))(); + if (read.refusal) return refuse(`${read.refusal}; ${TOKEN_ADD_HINT}`); + const operators = read.file.tokens.filter((t) => t.role === "operator").map((t) => t.name); + let name = args.name; + if (name !== null) { + const entry = read.file.tokens.find((t) => t.name === name); + if (!entry) return refuse(`there is no token named "${name}"; see pi-dispatch-console token list`); + if (entry.role !== "operator") return refuse(`"${name}" is a ${entry.role} token, and the panel signs in operators only; use pi-dispatch-console link --name ${name} in a shell for it`); + } else if (operators.length === 0) { + return refuse(`the tokens file holds no operator token; ${TOKEN_ADD_HINT}`); + } else if (operators.length === 1) { + name = operators[0]; + } else { + if (typeof deps.select !== "function") return refuse(`the tokens file holds ${operators.length} operator tokens; name one: /dispatch console --link `); + const picked = await deps.select("Sign in to the console as which operator token?", operators); + if (typeof picked !== "string" || !operators.includes(picked)) { + notify([...lines, "no sign-in link was made: no token was chosen"].join("\n"), "info"); + return { refused: "cancelled", state: answer.state }; + } + name = picked; + } + // Audit, then mint (the panel's rule): a code with no row in the log is never made. + // One ticket on both rows, as `auditedWrite` does, so a reader ties a `failed` row to its `ok` row. + const audit = deps.audit ?? panelAudit(deps.logsDir); + const ticket = deps.ticket ?? writeTicket(); + try { + audit.append({ actor: deps.actor ?? panelActor(), action: "login-code", target: name, outcome: "ok", ticket }); + } catch (e) { + return refuse(`the audit log could not be written (${typeof e?.code === "string" ? e.code : "error"}), so no code was made; check PI_LOGS_DIR`); + } + const mint = deps.mint ?? ((o) => mintLoginCode(o)); + const made = mint({ dir: codesDir, name }); + if (made.refusal) { + // The row says ok and no code exists: the safe direction to be wrong in, and said here. + try { + audit.append({ actor: deps.actor ?? panelActor(), action: "login-code", target: name, outcome: "failed", reason: made.refusal, ticket }); + } catch { + // the refusal below is what the operator must see + } + return refuse(made.refusal); + } + minted = true; + const next = path === "" ? "" : `&next=${fragmentValue(`/${path}`)}`; + if (at.socket) { + lines.push(`sign-in link for "${name}" (through that tunnel; it works once, within 60 seconds): http://localhost:4790/login/code#${made.code}${next}`); + } else { + const base = at.url.slice(0, at.url.length - path.length); + const link = `${base}login/code#${made.code}${next}`; + lines.push(`sign-in link for "${name}" (it works once, within 60 seconds): ${link}`); + url = at.anyHost ? null : link; + } + } + + let opened = false; + const isLocal = deps.isLocal ?? isLocalBrowserSession; + if (url !== null && answer.state === "running") { + if (args.noOpen) { + // Said nothing: the operator asked for the URL alone. + } else if (isLocal({ env: deps.sessionEnv ?? process.env, platform })) { + if (minted) { + const o = (deps.openLink ?? ((u) => openLinkViaFile(u, { open: deps.open ?? openBrowser })))(url); + if (o?.refusal) lines.push(o.refusal); + else opened = true; + } else { + (deps.open ?? openBrowser)(url); + opened = true; + } + // "Handed to", not "opened in": the opener is told, and whether the browser shows the page is the browser's. On + // Linux a snap or Flatpak browser has a /tmp of its own, so the private page is "not found" there. + if (opened) lines.push(minted && platform !== "darwin" && platform !== "win32" ? "handed to your browser (a snap or Flatpak browser cannot read the page in /tmp and shows it as not found: open the link above instead)" : "handed to your browser"); + } else { + lines.push("not opened: this is not a local session (SSH, or no display), so open the URL yourself"); + } + } + notify(lines.join("\n"), answer.state === "not-running" ? "warning" : "info"); + return { state: answer.state, url, opened, minted }; +} diff --git a/admin/src/console/link.mjs b/admin/src/console/link.mjs index 624fe7c9..e82b1ed9 100644 --- a/admin/src/console/link.mjs +++ b/admin/src/console/link.mjs @@ -3,8 +3,10 @@ * insights move to the console): `/dispatch insights`, `/dispatch insights whatif` and the dashboard's `i` key print * the spend screen's address. The address is the deployment's `PI_CONSOLE_LISTEN`, parsed by the console's own * `parseListen`, else the console's default (`DEFAULT_LISTEN`), so the URL printed is the one `pi-dispatch-console - * serve` would bind. Nothing here checks that a console is running: a probe would be a network call from the panel for - * a line of text, and the line says how to start one instead. + * serve` would bind. Nothing HERE checks that a console is running; the worker's `console-probe.mjs` does, for + * `/dispatch console` and doctor alike (issue #708). It once was "a network call from the panel for a line of text", and declined for that. The reversal's reason: + * the panel printed an address that did not answer, with nothing to say so, which is the silent failure this project + * refuses. The probe stays bounded to this host's loopback or the socket (`consoleProbeTarget`). */ import { isIPv4 } from "node:net"; @@ -12,7 +14,7 @@ import { DEFAULT_LISTEN } from "./config.mjs"; import { listenUrl, parseListen } from "./listen.mjs"; /** True for the any-address binds, `0.0.0.0` and `::` in any spelling (`[::]`, `0:0:0:0:0:0:0:0`, `::0`). */ -function isUnspecified(host) { +export function isUnspecified(host) { if (isIPv4(host)) return host === "0.0.0.0"; try { return new URL(`http://[${host}]/`).hostname === "[::]"; @@ -51,8 +53,14 @@ export function consolePageUrl(env, path, { platform = process.platform } = {}) return { url: `${listenUrl(listen)}${path}`, defaulted: declared === "" }; } +/** + * How to start a console that is not running: ONE constant, so a new way to run it (a service) is added in one place and + * every surface that says it (this hint and `/dispatch console`'s "not running" line) says the same. + */ +export const CONSOLE_START_ADVICE = "start it with pi-dispatch-console serve, or keep it running with pi-dispatch service install --console (docs/console.md)"; + /** How to start the console, said wherever its address is printed. */ -export const CONSOLE_START_HINT = "if the console is not running, start it with pi-dispatch-console serve (docs/console.md)"; +export const CONSOLE_START_HINT = `if the console is not running, ${CONSOLE_START_ADVICE}`; /** * The lines a panel surface prints for console page `path`: the URL (or the tunnel to take, or why the address is diff --git a/admin/src/console/login-codes.mjs b/admin/src/console/login-codes.mjs index 9062ff0b..09cb5757 100644 --- a/admin/src/console/login-codes.mjs +++ b/admin/src/console/login-codes.mjs @@ -18,6 +18,8 @@ import { dirname, join } from "node:path"; /** A code: 32 random bytes as base64url, always 43 characters. */ const CODE_RE = /^[A-Za-z0-9_-]{43}$/; const CODE_FILE_RE = /^[0-9a-f]{64}\.json$/; +/** How far a code's `expiresAt` may sit past `now + ttlMs` (a clock stepped between mint and consume). */ +const EXPIRY_SKEW_MS = 5000; /** The drop directory: `console-login-codes` beside the tokens file. */ export function loginCodesDir(tokensPath) { @@ -56,19 +58,35 @@ function dirProblem(dir, fs, { create }) { if (st.isSymbolicLink()) return `the login-code directory ${dir} is a symbolic link, and the console uses only a real directory; remove the link and run the command again`; if (!st.isDirectory()) return `${dir} is not a directory; remove it and run the command again`; if ((st.mode & 0o077) !== 0) { - return `the login-code directory ${dir} can be reached by other accounts (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}); run \`chmod 700 ${dir}\``; + // NOT "chmod 700": a directory another account could write may already hold a code it planted (issue #708, review + // round 1), and tightening the mode would make that code good. Its contents cannot be trusted, so it goes. + return `the login-code directory ${dir} can be reached by other accounts (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}), so what it holds cannot be trusted; remove the directory (\`rm -r ${dir}\`) and make a new link, which creates it again 0700`; } return null; } +/** + * This process's effective uid, or undefined where there is none (Windows), where the owner check is skipped. Not + * because the directory check holds there instead: Node reports group and other bits on every Windows file and + * directory (a mode such as 0o666 or 0o777), so `dirProblem` refuses every drop and no code is ever minted or spent on + * a Windows host. One-time links (`pi-dispatch-console link`, `/dispatch console --link`) are not supported there; a + * token is pasted instead (docs/console.md). + */ +function processUid() { + return typeof process.geteuid === "function" ? process.geteuid() : undefined; +} + /** * Read one code file and remove it: `{name, expiresAt}` when THIS call removed it, else null (gone, already taken, or - * not a regular file, which is removed too: nothing else belongs here). + * not a regular file, which is removed too: nothing else belongs here). Defence in depth beside the directory check + * (issue #708): a file with any group or other permission bit, or one another account owns, was not written by + * `mintLoginCode` (0600, this account), so it is removed and refused rather than read. */ -function take(file, fs) { +function take(file, fs, uid) { let text; try { - if (!fs.lstatSync(file).isFile()) { + const st = fs.lstatSync(file); + if (!st.isFile() || (uid !== undefined && ((st.mode & 0o077) !== 0 || st.uid !== uid))) { fs.unlinkSync(file); return null; } @@ -150,18 +168,21 @@ export function mintLoginCode({ dir, name, fs = nodeFs, now = () => Date.now(), * Spend a code: `{name}` for the one caller that removes its file before it expires, `{refusal}` for everyone else * (unknown, already used, expired, malformed). The refusal never quotes the code. */ -export function consumeLoginCode({ dir, code, fs = nodeFs, now = () => Date.now() }) { +export function consumeLoginCode({ dir, code, fs = nodeFs, now = () => Date.now(), ttlMs = 60000, uid = processUid() }) { const used = "this login link is not valid: it was already used, has expired, or was never made. Run `pi-dispatch-console link --name ` for a new one"; if (typeof code !== "string" || !CODE_RE.test(code)) return { refusal: used }; const problem = dirProblem(dir, fs, { create: false }); if (problem) return { refusal: problem }; const t = now(); - const taken = take(join(dir, `${codeHash(code)}.json`), fs); + const taken = take(join(dir, `${codeHash(code)}.json`), fs, uid); reap(dir, fs, t); if (!taken) return { refusal: used }; if (!Number.isFinite(taken.expiresAt) || taken.expiresAt <= t) { return { refusal: "this login link has expired. Run `pi-dispatch-console link --name ` for a new one" }; } + // A code the minter made expires at most `ttlMs` after it was made; one that claims longer (allowing a few seconds of + // clock step) was written by something else. + if (taken.expiresAt > t + ttlMs + EXPIRY_SKEW_MS) return { refusal: used }; if (typeof taken.name !== "string" || taken.name === "") return { refusal: used }; return { name: taken.name }; } diff --git a/admin/src/console/open-link.mjs b/admin/src/console/open-link.mjs new file mode 100644 index 00000000..f475a6da --- /dev/null +++ b/admin/src/console/open-link.mjs @@ -0,0 +1,96 @@ +/** + * Open a sign-in link without putting its code on a command line (issue #708, review round 1). The opener is a child + * process (`open`, `xdg-open`, `rundll32`), and a child's argv is readable by every local account (`/proc//cmdline`, + * `ps`) for as long as it runs, so a link handed to it as an argument hands the code to them for that time. Instead the + * link is written into a small HTML page that sends the browser on with `location.replace` (the fragment, which carries + * the code, kept), and the OPENER is given that page's path, which says nothing. + * + * The page is a 0600 file in a fresh 0700 directory under the OS temp directory (`mkdtemp`, then chmod exactly, since + * the mode is masked by the umask), so no other account can read it. It is removed after `keepMs` (long enough for the + * browser to read it, and the code is good for 60 seconds anyway), and any page an earlier run left (a pi that exited + * first) is removed on the next one, once it is older than `keepMs`: a second link within that time must not remove a + * page another session's browser has yet to read. + * + * What stays exposed, named rather than implied: the notice that shows the link (the operator's own terminal, and + * whoever watches it); the browser's history entry for the page and the link until the code is spent (the console's + * script removes the fragment from history before it posts the code); and a pi attached over tmux from an SSH session + * whose environment says "local", which opens the browser on the machine's own desktop, unattended. And what opens + * the page is the desktop's handler for `.html` files, which is usually but not always the browser; on Linux a snap or + * Flatpak browser has a private /tmp and cannot read the page at all (the notice then says to open the printed link). + */ +import * as nodeFs from "node:fs"; +import { tmpdir as osTmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** The prefix of the temporary directories, so the next run can find what an earlier one left. */ +export const OPEN_DIR_PREFIX = "pi-dispatch-console-open-"; + +/** How long the redirect page lives. */ +export const OPEN_KEEP_MS = 60000; + +/** The redirect page for `url`: the URL as a JSON string literal, with `<` escaped so it cannot close the script. */ +export function redirectPage(url) { + const literal = JSON.stringify(String(url)).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); + return `\n\n\npi-dispatch console\n\n`; +} + +/** + * Remove what earlier runs left: every `OPEN_DIR_PREFIX` directory under `base` this account owns whose mtime is older + * than `keepMs`. Never throws. + */ +function sweep(base, fs, uid, keepMs, now) { + let names; + try { + names = fs.readdirSync(base); + } catch { + return; + } + for (const name of names) { + if (!name.startsWith(OPEN_DIR_PREFIX)) continue; + const dir = join(base, name); + try { + const st = fs.lstatSync(dir); + if (!st.isDirectory() || (uid !== undefined && st.uid !== uid) || now - st.mtimeMs < keepMs) continue; + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // gone, or not ours to remove + } + } +} + +/** + * Open `url` through a private redirect page. `open(target)` is the opener (`openBrowser`), handed a `file:` URL. + * Returns `{ opened: true, page }` or `{ refusal }` (the page could not be made private, so nothing was opened). + */ +export function openLinkViaFile(url, { open, fs = nodeFs, tmpdir = osTmpdir(), keepMs = OPEN_KEEP_MS, setTimer = setTimeout, now = () => Date.now(), uid = typeof process.geteuid === "function" ? process.geteuid() : undefined } = {}) { + sweep(tmpdir, fs, uid, keepMs, now()); + let dir; + try { + dir = fs.mkdtempSync(join(tmpdir, OPEN_DIR_PREFIX)); + fs.chmodSync(dir, 0o700); + const page = join(dir, "open.html"); + fs.writeFileSync(page, redirectPage(url), { mode: 0o600, flag: "wx" }); + fs.chmodSync(page, 0o600); + open(pathToFileURL(page).href); + const timer = setTimer(() => { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // the next run's sweep takes it + } + }, keepMs); + // The page must not keep pi running; a page left by an exit is the next run's to sweep. + timer?.unref?.(); + return { opened: true, page }; + } catch (e) { + if (dir !== undefined) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // nothing more to do + } + } + return { refusal: `the browser was not opened: a private page for the link could not be made (${typeof e?.code === "string" ? e.code : "error"})` }; + } +} diff --git a/admin/src/console/pages.mjs b/admin/src/console/pages.mjs index f4de4036..9f36c66c 100644 --- a/admin/src/console/pages.mjs +++ b/admin/src/console/pages.mjs @@ -241,6 +241,37 @@ export function pageQueryString(name, query) { return p.toString(); } +// ---- where a sign-in lands ------------------------------------------------------------------------------------------ + +/** The longest return path accepted: a page path and a query within `QUERY_LIMITS`, with room to spare. */ +export const RETURN_PATH_MAX = 1024; + +/** + * The page a one-time sign-in link returns to (issue #708: `login/code#&next=/runs`), or null for anything that + * is not one of THIS console's own pages. The `next` value arrives from a link anyone could have written, so it is an + * open redirect unless it is held to the console's own pages: a string of at most `RETURN_PATH_MAX` characters, + * starting with exactly one `/` (`//evil` is a scheme-relative URL to another host), no backslash (a browser reads + * `/\evil` as `//evil`), no control byte, no fragment, that parses to this origin, whose path is a `PAGES` path, and + * whose query passes that page's own `parsePageQuery`. `/login` and `/login/code` are not pages, so a link cannot loop + * back into sign-in. Returns the path and query as given (`/run?id=a.b`), since the page parser accepted them. + */ +export function returnPathOf(raw) { + if (typeof raw !== "string" || raw === "" || raw.length > RETURN_PATH_MAX) return null; + if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\") || raw.includes("#") || /[\u0000-\u001f\u007f]/.test(raw)) return null; + let url; + try { + url = new URL(raw, "http://console.invalid"); + } catch { + return null; + } + if (url.origin !== "http://console.invalid") return null; + const name = Object.keys(PAGES).find((k) => PAGES[k].path === url.pathname); + if (name === undefined) return null; + const parsed = parsePageQuery(name, url.searchParams); + if (!parsed || parsed.refusal || !("query" in parsed)) return null; + return `${url.pathname}${url.search}`; +} + // ---- reading --------------------------------------------------------------------------------------------------------- /** What each screen reads beside the clock. */ diff --git a/admin/src/console/script.mjs b/admin/src/console/script.mjs index 2a15c72a..9e349c62 100644 --- a/admin/src/console/script.mjs +++ b/admin/src/console/script.mjs @@ -9,7 +9,9 @@ * - `login-code`: reads the one-time code from `location.hash`, drops the fragment from history with * `history.replaceState` BEFORE posting it (so the back button and a session restore cannot replay it), then posts * it. The code travels in the fragment because a fragment never reaches the server's request line, a log or a - * Referer. + * Referer. A fragment `#&next=` (issue #708) also names the page to land on: it is posted beside the + * code, the server answers the page it accepts (`returnPathOf`, one of this console's own pages, else `/`), and + * the script navigates there only if the answer is also one of `PAGES`' paths (`ownPage`), never anywhere else. * - every signed-in page: the sign-out button (`#logout`). * - a LIVE page (issue #641; `data-live` on the body): opens `/api/events?page=&` with * `EventSource`. An `event: panel` carries `{id, html}`, and its html replaces the inner HTML of the element with @@ -47,10 +49,12 @@ * swap and the transcript append above, all of server-escaped fragments into elements the server marked for them. */ import { FIT_JS } from "../graph-html.mjs"; +import { PAGES } from "./pages.mjs"; export const CONSOLE_SCRIPT = `"use strict"; (function () { var page = document.body ? document.body.getAttribute("data-page") : null; + var PAGE_PATHS = ${JSON.stringify(Object.values(PAGES).map((p) => p.path))}; function say(text) { var el = document.getElementById("notice"); if (el) el.textContent = text; @@ -83,17 +87,31 @@ export const CONSOLE_SCRIPT = `"use strict"; }, function () { say("The console could not be reached."); }); }); } else if (page === "login-code") { - var code = location.hash.length > 1 ? location.hash.slice(1) : ""; + var frag = location.hash.length > 1 ? location.hash.slice(1) : ""; history.replaceState(null, "", location.pathname); + var amp = frag.indexOf("&"); + var code = amp < 0 ? frag : frag.slice(0, amp); + var next = null; + if (amp >= 0) { + try { next = new URLSearchParams(frag.slice(amp + 1)).get("next"); } catch (e) { next = null; } + } if (code === "") { say("This link carries no code. Ask for a new one with pi-dispatch-console link."); } else { - post("/login/code", { code: code }).then(function (r) { - if (r.ok) location.replace("/"); + post("/login/code", next === null ? { code: code } : { code: code, next: next }).then(function (r) { + if (r.ok) location.replace(ownPage(r.body && r.body.next)); else failed(r); }, function () { say("The console could not be reached."); }); } } + // Where a sign-in may land: one of this console's own page paths, else the overview. The server has already judged + // the return path with the page parser; this repeats the shape check, so a navigation off this origin needs both + // to be wrong. + function ownPage(path) { + if (typeof path !== "string" || path.charAt(0) !== "/" || path.charAt(1) === "/" || path.indexOf("\\\\") >= 0 || path.indexOf("#") >= 0) return "/"; + var q = path.indexOf("?"); + return PAGE_PATHS.indexOf(q < 0 ? path : path.slice(0, q)) >= 0 ? path : "/"; + } var out = document.getElementById("logout"); if (out) out.addEventListener("click", function () { post("/logout", {}).then(function () { location.assign("/login"); }, function () { location.assign("/login"); }); diff --git a/admin/src/console/server.mjs b/admin/src/console/server.mjs index c895badb..244d45a2 100644 --- a/admin/src/console/server.mjs +++ b/admin/src/console/server.mjs @@ -115,7 +115,7 @@ function actorOf(ctx) { * Create a session for `entry` (a tokens-file entry), with a NEW id whatever cookie came in (no fixation: a cookie * planted before sign-in is destroyed, never promoted), and audit it. An audit failure refuses the sign-in. */ -function startSession(ctx, entry, file, action) { +function startSession(ctx, entry, file, action, extra = {}) { const { deps, res } = ctx; const presented = deps.cookies.readSessionCookie(ctx.req.headers.cookie); if (presented) deps.sessions.destroy(presented); @@ -128,7 +128,7 @@ function startSession(ctx, entry, file, action) { return sendError(res, 500, "the audit log could not be written, so the sign-in was refused; check the console's log directory"); } const cookie = deps.cookies.sessionCookie(id, { maxAgeSec: Math.floor(deps.config.maxMs / 1000) }); - return sendJson(res, 200, { ok: true, name: entry.name, role: entry.role }, { "set-cookie": cookie }); + return sendJson(res, 200, { ok: true, name: entry.name, role: entry.role, ...extra }, { "set-cookie": cookie }); } /** The tokens file, or null after answering 503 (a file that went bad while the console ran: nobody signs in). */ @@ -174,7 +174,10 @@ async function postLoginCode(ctx) { if (!file) return; const entry = file.tokens.find((t) => t.name === consumed.name); if (!entry) return refuse("the link named a token that no longer exists"); - return startSession(ctx, entry, file, "login-code"); + // Issue #708: the page the link returns to, judged HERE by the page parser (`returnPathOf`), so the browser is only + // ever sent to one of this console's own pages; anything else, or nothing, lands on the overview. The script checks + // the answer's shape again before it navigates. + return startSession(ctx, entry, file, "login-code", { next: realPages.returnPathOf(ctx.body.next) ?? "/" }); } async function postLogout(ctx) { diff --git a/admin/src/index.ts b/admin/src/index.ts index 5c25fee7..e2a3c355 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -109,6 +109,8 @@ import { // deployment's money files, which only the confirm-gated tools and the operator's own hand may change. import { envNamedFiles, makeWriteGuard } from "./write-guard.mjs"; import { consolePageLines } from "./console/link.mjs"; +import { CONSOLE_PAGE_WORDS, consoleCommand } from "./console-command.mjs"; +import { loginCodesDir } from "./console/login-codes.mjs"; import { WHATIF_MODEL_RE } from "./console/whatif.mjs"; import { SKILL_NAME_RE } from "@edgehero/pi-dispatch/flow-gate"; // The insights payload's assembly, pi-free so the operator console shares it (issue #641). The allocation seams live @@ -288,7 +290,7 @@ const CHANNEL = "pi-dispatch-admin"; const USAGE = - "usage: /dispatch "; + "usage: /dispatch "; const KNOWN_SUBCOMMANDS = [ "status", @@ -300,6 +302,7 @@ const KNOWN_SUBCOMMANDS = [ "chat", "budget", "insights", + "console", "triggers", "settings", "set", @@ -352,7 +355,7 @@ export default function admin(pi: ExtensionAPI): void { // which is exactly the drift the USAGE/KNOWN_SUBCOMMANDS pin exists to catch -- keep all three // in step. description: - "pi-dispatch admin: status|pause|resume|run|runs|logs|chat|budget|insights|triggers|settings|set|unset|priorities|setup", + "pi-dispatch admin: status|pause|resume|run|runs|logs|chat|budget|insights|console|triggers|settings|set|unset|priorities|setup", getArgumentCompletions: (prefix) => completeArguments(prefix), handler: async (args, ctx) => dispatch(pi, args, ctx), }); @@ -376,7 +379,7 @@ export default function admin(pi: ExtensionAPI): void { * same reason and for the worker's: a service restart reads them. */ function registerWriteGuard(pi: ExtensionAPI): void { - const seen = new Map(); + const seen = new Map(); const remember = () => { for (const g of guardedFiles()) if (!seen.has(g.path)) seen.set(g.path, g); return [...seen.values()]; @@ -400,7 +403,38 @@ function registerWriteGuard(pi: ExtensionAPI): void { */ const CONSOLE_TOKENS_GUARD = { label: "the console tokens file (PI_CONSOLE_TOKENS_FILE)", tool: "`pi-dispatch-console token add`, `revoke` or `rotate`, in the operator's own shell" }; -export function guardedFiles(): { path: string; label: string; tool: string }[] { +type Guarded = { path: string; label: string; tool: string; dir?: boolean }; + +/** + * The console tokens file and, beside it, its one-time login codes (`console-login-codes/`, `loginCodesDir`), guarded + * as a DIRECTORY: every path under it. The login-codes directory joins the guarded files, as the tokens file is, + * because a code file there is a sign-in as the token it names: a model that could `write` `.json` into + * it could mint itself a console session as an operator, the same reach as adding a token. Only the operator makes + * codes (`/dispatch console --link`, `pi-dispatch-console link`); the residual is `bash`, as for the tokens file. + */ +function consoleTokenGuards(tokensPath: string | null): Guarded[] { + if (tokensPath === null) return []; + return [ + { path: tokensPath, ...CONSOLE_TOKENS_GUARD }, + { path: loginCodesDir(tokensPath), label: "the console's one-time login codes (console-login-codes, beside the tokens file)", tool: "`/dispatch console --link` or `pi-dispatch-console link`, typed by the operator", dir: true }, + ]; +} + +/** + * The console tokens file the panel sees, or null where it sees none: the pointer's deployment folder's (its key, else + * the default name there); with no pointer, the session folder's when `PI_CONSOLE_TOKENS_FILE` is set or the folder is + * an init scaffold. ONE helper for the guard and for `/dispatch console --link` (issue #708, review round 1), so the + * command mints only into the drop beside a tokens file this guard holds, never one it computed some other way. + */ +function consoleTokensPathFor(env: any, dir: unknown): string | null { + if (typeof dir === "string" && dir !== "") return tokensFilePath(env, dir); + const cwd = process.cwd(); + if (typeof env?.PI_CONSOLE_TOKENS_FILE === "string" && env.PI_CONSOLE_TOKENS_FILE !== "") return tokensFilePath(env, cwd); + if (DEPLOYMENT_SCAFFOLD_FILES.every((f) => nodeFs.existsSync(join(cwd, f)))) return tokensFilePath(env, cwd); + return null; +} + +export function guardedFiles(): Guarded[] { const env = deploymentEnv(); const paths = resolvePaths(deploymentEnv()); const set = (key: string) => typeof env?.[key] === "string" && env[key] !== ""; @@ -415,8 +449,8 @@ export function guardedFiles(): { path: string; label: string; tool: string }[] { path: pointerPath(process.env), label: "the deployment pointer (PI_DISPATCH_DEPLOYMENT_FILE)", tool: "/dispatch setup" }, // Issue #640: the console's tokens file. A model that could write it could add a token of its own and sign in to // the console as an operator. Its default lives in the deployment folder, so it is guarded wherever the folder is - // known (the pointer's here, an init scaffold folder below) and, with the key set, wherever that key resolves. - { path: typeof dir === "string" && dir !== "" ? tokensFilePath(env, dir) : set("PI_CONSOLE_TOKENS_FILE") ? tokensFilePath(env, process.cwd()) : null, ...CONSOLE_TOKENS_GUARD }, + // known (the pointer's, an init scaffold folder) and, with the key set, wherever that key resolves (`consoleTokensPathFor`). + ...consoleTokenGuards(consoleTokensPathFor(env, dir)), ]; if (!(typeof dir === "string" && dir !== "")) { // No pointer: the session folder may BE the deployment (`init` then `up`, pi started there, nothing exported). Its @@ -432,11 +466,10 @@ export function guardedFiles(): { path: string; label: string; tool: string }[] { path: paths.triggersPath, label: "triggers.json (PI_TRIGGERS_FILE)", tool: "dispatch_trigger_add, _edit or _delete" }, { path: paths.scopedLimitsPath, label: "scoped-limits.json (PI_SCOPED_LIMITS_FILE)", tool: "dispatch_limit_add, _edit or _delete" }, { path: paths.projectsPath, label: "projects.json (PI_PROJECTS_FILE)", tool: "dispatch_project_add, _edit or _delete" }, - { path: tokensFilePath(env, cwd), ...CONSOLE_TOKENS_GUARD }, ); } } - return out.filter((g): g is { path: string; label: string; tool: string } => typeof g.path === "string" && g.path !== ""); + return out.filter((g): g is Guarded => typeof g.path === "string" && g.path !== ""); } /** @@ -1766,6 +1799,25 @@ async function dispatch(pi: ExtensionAPI, args: string, rawCtx: any): Promise guardedFiles().some((g) => g.dir === true && g.path === codesDir), + }); + return; + } case "run": { const folder = tokens[1]; const flow = tokens[2]; @@ -2931,6 +2983,15 @@ function completeArguments(prefix: string) { .map((id) => ({ value: `chat ${id}`, label: id })); return items.length > 0 ? items : null; } + if (parts[0] === "console" && parts.length >= 2) { + // The page words and the flags; a run id is typed whole (or completed under `logs`). + const partial = parts[parts.length - 1]; + const head = parts.slice(0, -1).join(" "); + const items = [...CONSOLE_PAGE_WORDS, "--link", "--no-open"] + .filter((w) => w.startsWith(partial) && !parts.slice(1, -1).includes(w)) + .map((w) => ({ value: `${head} ${w}`, label: w })); + return items.length > 0 ? items : null; + } if (parts[0] === "insights" && parts.length === 2) { const partial = parts[1]; const items = ["7d", "30d", "mtd", "whatif"] diff --git a/admin/src/read-model.mjs b/admin/src/read-model.mjs index 3fceb74a..fc482090 100644 --- a/admin/src/read-model.mjs +++ b/admin/src/read-model.mjs @@ -4310,7 +4310,7 @@ export async function readRunsPage(redis, { logsDir, before = null, limit = 50, } /** A job id this reader will look up: the shapes the worker mints (`gh-…`, `repeat::`, a uuid), bounded. */ -const RUN_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/; +export const RUN_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/; /** * One run by its job id (issue #641): `{ record, source }` with `source` "local" or "mirror", `{ missing: true }`, or diff --git a/admin/src/write-guard.mjs b/admin/src/write-guard.mjs index f3aaf935..b9b7e08a 100644 --- a/admin/src/write-guard.mjs +++ b/admin/src/write-guard.mjs @@ -10,7 +10,10 @@ * settings overlay, at the paths this deployment resolves (the pointer and the deployment `.env` included), and * the two files that NAME those paths, the deployment's `.env` and the pointer itself (index.ts `guardedFiles`, * whose set only grows within a session); - * - not the overlay's `models.json`: the admin never writes it, so no tool's confirm is bypassed there. + * - not the overlay's `models.json`: the admin never writes it, so no tool's confirm is bypassed there; + * - and, as a DIRECTORY (`dir: true`: every path under it, and the directory itself), the console's one-time login + * codes beside the tokens file (issue #708). The login-codes directory joins the guarded files, as the tokens file + * is: a code file planted there signs a browser in as the token it names. * * WHICH TOOLS is pinned against pi 0.99.1's `dist/core/tools/` (`allToolNames`) by a test: `write` and `edit` name * their target in `input.path`; `powershell` runs a command, judged by its text (a command that names a guarded file's @@ -45,6 +48,8 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, parse as parsePath, resolve as nodeResolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { loginCodesDir } from "./console/login-codes.mjs"; + /** pi's Unicode-space class (`dist/utils/paths.js` `UNICODE_SPACES`), copied; a test pins the literal. */ export const PI_UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; @@ -172,10 +177,14 @@ export const GUARDED_ENV_KEYS = Object.freeze({ PI_SCOPED_LIMITS_FILE: { label: "scoped-limits.json (PI_SCOPED_LIMITS_FILE)", tool: "dispatch_limit_add, _edit or _delete" }, PI_TRIGGERS_FILE: { label: "triggers.json (PI_TRIGGERS_FILE)", tool: "dispatch_trigger_add, _edit or _delete" }, PI_SETTINGS_FILE: { label: "the settings overlay (PI_SETTINGS_FILE)", tool: "dispatch_set" }, - // Issue #640: a token here is a login to the operator console, which no tool of this session may mint. + // Issue #640: a token here is a login to the operator console, which no tool of this session may mint. Issue #708: + // the key also places the login-codes directory beside the file (`LOGIN_CODES_GUARD`), which no key names itself. PI_CONSOLE_TOKENS_FILE: { label: "the console tokens file (PI_CONSOLE_TOKENS_FILE)", tool: "`pi-dispatch-console token add`, `revoke` or `rotate`, in the operator's own shell" }, }); +/** The login-codes directory's guard entry (issue #708), minus its path: guarded as a directory. */ +export const LOGIN_CODES_GUARD = Object.freeze({ label: "the console's one-time login codes (console-login-codes, beside the tokens file)", tool: "`/dispatch console --link` or `pi-dispatch-console link`, typed by the operator", dir: true }); + /** The most of a `.env` the guard reads: far above any real one, far below what a device or a huge file could cost. */ export const ENV_READ_MAX = 1024 * 1024; @@ -246,12 +255,90 @@ export function envNamedFiles(dir, { fs = nodeFs, home = homedir() } = {}) { readings.add(w.replace(/^(?:~(?=\/|$)|\$HOME(?![A-Za-z0-9_])|\$\{HOME\})/, home)); } } - for (const r of readings) if (r.trim() !== "") out.push({ path: nodeResolve(dir, r), ...GUARDED_ENV_KEYS[key] }); + for (const r of readings) { + if (r.trim() === "") continue; + out.push({ path: nodeResolve(dir, r), ...GUARDED_ENV_KEYS[key] }); + if (key === "PI_CONSOLE_TOKENS_FILE") out.push({ path: loginCodesDir(nodeResolve(dir, r)), ...LOGIN_CODES_GUARD }); + } } if (out.length > 0) out.push({ path: join(dir, ".env"), label: "the deployment's .env, which names these files (PI_*_FILE)", tool: "the operator's own editor" }); return out; } +/** + * Where an absolute path really is, as the kernel would resolve it on create: `{ real, rest }`, `real` the nearest + * existing ancestor reached with every symlink on the way followed (so it holds no link), `rest` the folded segments + * below it that do not exist yet. THROWS on anything but "not there", like `targetIdentity`, whose walk this is. + */ +export function realLocation(absPath, { fs = nodeFs } = {}) { + let path = nodeResolve(absPath); + for (let hops = 0; hops <= MAX_LINKS; hops++) { + const { root } = parsePath(path); + const segments = path.slice(root.length).split(sep).filter(Boolean); + let cur = root; + let restarted = false; + for (let i = 0; i < segments.length; i++) { + const next = join(cur, segments[i]); + let st; + try { + st = fs.lstatSync(next); + } catch (err) { + if (!GONE.has(err?.code)) throw err; + return { real: cur, rest: segments.slice(i).map(fold) }; + } + if (st.isSymbolicLink()) { + path = nodeResolve(dirname(next), fs.readlinkSync(next), ...segments.slice(i + 1)); + restarted = true; + break; + } + cur = next; + } + if (!restarted) return { real: cur, rest: [] }; + } + const loop = new Error("too many symbolic links on the way"); + loop.code = "ELOOP"; + throw loop; +} + +/** + * Is `absPath` the directory `dirPath` or anything under it (issue #708, a `dir: true` guard)? Decided by identity, + * never by a path string, since a volume can be reached by more than one path that is not a symlink (a macOS firmlink + * such as `/System/Volumes/Data/...`, a bind mount, `/.vol//`): + * - where the directory exists: some existing ancestor of the target's real location (`realLocation`, links + * followed) is the directory's `(dev, ino)`, so a symlink into it, a case variant of its name and `..` all land + * on it; + * - where it does not exist yet (no code was ever made), as `targetIdentity` judges a file that does not exist: the + * directory is its nearest existing ancestor's `(dev, ino)` plus the folded segments below it, and the target is + * inside when one of its existing ancestors IS that ancestor (by identity) and the folded path from there down to + * the target begins with those segments. A string comparison here was bypassed through a firmlink alias (review + * round 1): pi's `write` then made the directory and the code file under the alias. + * THROWS like `targetIdentity`. + */ +export function insideDirectory(absPath, dirPath, { fs = nodeFs } = {}) { + const target = realLocation(absPath, { fs }); + let dirId = null; + try { + const st = fs.statSync(dirPath, { bigint: true }); + if (st.isDirectory()) dirId = idOf(st); + } catch (err) { + if (!GONE.has(err?.code)) throw err; + } + let wantId = dirId; + let wantRest = []; + if (dirId === null) { + const where = realLocation(dirPath, { fs }); + wantId = idOf(fs.statSync(where.real, { bigint: true })); + wantRest = where.rest; + } + for (let p = target.real; ; p = dirname(p)) { + if (idOf(fs.statSync(p, { bigint: true })) === wantId) { + const below = [...target.real.slice(p.length).split(sep).filter(Boolean).map(fold), ...target.rest]; + if (wantRest.every((seg, i) => below[i] === seg)) return true; + } + if (dirname(p) === p) return false; + } +} + /** Two identities name one file: the same existing file, or the same not-yet-existing name under the same directory. */ function sameFile(a, b) { if (a.kind !== b.kind || a.id !== b.id) return false; @@ -290,6 +377,20 @@ export function makeWriteGuard({ guardedFiles, fs = nodeFs, home = homedir(), pl const abs = piResolveToCwd(raw, typeof ctx?.cwd === "string" && ctx.cwd !== "" ? ctx.cwd : processCwd(), { home, platform }); const target = targetIdentity(abs, { fs }); for (const g of guarded) { + if (g.dir === true) { + const dirAbs = nodeResolve(processCwd(), g.path); + let inside; + try { + inside = insideDirectory(abs, dirAbs, { fs }); + } catch { + // As for a file below: judged by name, folded, when the identity cannot be read. + const f = fold(abs); + const d = fold(dirAbs); + inside = f === d || f.startsWith(`${d}${sep}`); + } + if (inside) return blocked(tool, g); + continue; + } let id; try { id = targetIdentity(nodeResolve(processCwd(), g.path), { fs }); diff --git a/admin/test/console-command.test.mjs b/admin/test/console-command.test.mjs new file mode 100644 index 00000000..a087adb4 --- /dev/null +++ b/admin/test/console-command.test.mjs @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { test } from "node:test"; +import { CONSOLE_USAGE, consoleCommand, consolePagePath, parseConsoleArgs, probeLine } from "../src/console-command.mjs"; +import { loginCodesDir } from "../src/console/login-codes.mjs"; + +// Issue #708: `/dispatch console`, driven through its seams. Every word goes to `notify`; the probe, the tokens file, +// the audit log, the mint and the opener are fakes that record what was asked of them, in order. + +const CODE = "C".repeat(43); +const SHA = "e".repeat(64); +const TOKENS_PATH = "/dep/console-tokens.json"; + +function harness({ hasUI = true, mode = "tui", env = {}, state = "running", tokens = [{ name: "alice", role: "operator" }], tokensRefusal = null, auditFails = false, mintRefusal = null, select = null, local = true, guarded = true, tokensPath = TOKENS_PATH } = {}) { + const order = []; + const notes = []; + const rows = []; + const opened = []; + const probes = []; + const selects = []; + const deps = { + hasUI, + mode, + notify: (text, level) => notes.push([text, level]), + select: select ? async (title, options) => (selects.push([title, options]), select(options)) : undefined, + env, + sessionEnv: {}, + platform: "darwin", + logsDir: "/dep/logs", + tokensPath, + guarded: (dir) => (order.push("guarded"), guarded && dir === loginCodesDir(TOKENS_PATH)), + ticket: "0123456789ab", + probe: async (e) => (probes.push(e), order.push("probe"), { state, ...(state === "unknown" ? { reason: "nothing answered within 1000 ms" } : {}) }), + readTokens: () => (order.push("read"), tokensRefusal ? { refusal: tokensRefusal } : { file: { version: 1, generation: 1, tokens: tokens.map((t) => ({ ...t, sha256: SHA, createdAt: "2026-10-01T00:00:00.000Z" })) } }), + audit: { + append: (row) => { + order.push(`audit:${row.outcome}`); + if (auditFails) { + const e = new Error("disk full"); + e.code = "ENOSPC"; + throw e; + } + rows.push(row); + }, + }, + actor: { name: "rob", role: "panel", session: null }, + mint: (o) => (order.push("mint"), assert.equal(o.dir, loginCodesDir(TOKENS_PATH)), mintRefusal ? { refusal: mintRefusal } : { code: CODE }), + open: (url) => (order.push("open"), opened.push(url)), + openLink: (url) => (order.push("openLink"), opened.push(url), { opened: true }), + isLocal: () => local, + }; + return { deps, order, notes, rows, opened, probes, selects }; +} + +const text = (h) => h.notes.map(([t]) => t).join("\n"); + +test("parseConsoleArgs and consolePagePath: the page words, a run id, --link [name], --no-open; anything else is usage", () => { + assert.deepEqual(parseConsoleArgs([]), { page: null, link: false, name: null, noOpen: false }); + assert.deepEqual(parseConsoleArgs(["runs", "--link", "alice", "--no-open"]), { page: "runs", link: true, name: "alice", noOpen: true }); + assert.deepEqual(parseConsoleArgs(["--link", "--no-open"]), { page: null, link: true, name: null, noOpen: true }); + for (const bad of [["runs", "fleet"], ["--open"], ["--link", "a", "--link"], ["--no-open", "--no-open"], ["-x"]]) assert.equal(parseConsoleArgs(bad).usage, true, bad.join(" ")); + const pages = { runs: "runs", fleet: "fleet", insights: "insights", config: "config", parked: "parked", audit: "audit", "gh-acme-widgets-42.1": "run?id=gh-acme-widgets-42.1", "repeat:t1:1760000000000": "run?id=repeat:t1:1760000000000" }; + for (const [word, path] of Object.entries(pages)) assert.equal(consolePagePath(word), path, word); + assert.equal(consolePagePath(null), ""); + for (const bad of ["", "a/b", "x".repeat(201), "run id"]) assert.equal(consolePagePath(bad), null, bad); +}); + +test("without pi's UI it refuses before anything is probed, read or minted", async () => { + const h = harness({ hasUI: false }); + assert.deepEqual(await consoleCommand(["--link"], h.deps), { refused: "no-ui" }); + assert.deepEqual(h.order, []); + assert.equal(h.notes[0][1], "warning"); +}); + +test("a bad word answers the usage line and probes nothing", async () => { + for (const words of [["nonsense/page"], ["runs", "fleet"], ["--bogus"]]) { + const h = harness(); + await consoleCommand(words, h.deps); + assert.deepEqual(h.notes, [[CONSOLE_USAGE, "warning"]], words.join(" ")); + assert.deepEqual(h.order, []); + } +}); + +test("the address and the state: running, not running (with how to start it), unknown (with why)", async () => { + const running = harness(); + const r = await consoleCommand(["runs"], running.deps); + assert.equal(r.state, "running"); + assert.equal(running.notes.length, 1); + assert.deepEqual(running.notes[0][0].split("\n").slice(0, 2), ["http://127.0.0.1:4790/runs", "the console: running"]); + assert.equal(running.notes[0][1], "info"); + + const stopped = harness({ state: "not-running" }); + await consoleCommand(["gh-1"], stopped.deps); + assert.match(text(stopped), /^http:\/\/127\.0\.0\.1:4790\/run\?id=gh-1\nthe console: not running: start it with pi-dispatch-console serve, or keep it running with pi-dispatch service install --console/); + assert.equal(stopped.notes[0][1], "warning"); + assert.deepEqual(stopped.opened, [], "a console that is not running is not opened"); + + const unknown = harness({ state: "unknown", env: { PI_CONSOLE_LISTEN: "127.0.0.1:0" } }); + await consoleCommand([], unknown.deps); + assert.match(text(unknown), /port the OS picks/); + assert.match(text(unknown), /the console: whether it runs is not known: nothing answered/); + + const sock = harness({ env: { PI_CONSOLE_LISTEN: "unix:/run/pd/c.sock" } }); + await consoleCommand(["fleet"], sock.deps); + assert.match(text(sock), /ssh -L 4790:\/run\/pd\/c\.sock , then open http:\/\/localhost:4790\/fleet/); + assert.deepEqual(sock.opened, [], "a socket is reached through a tunnel, never opened"); + assert.equal(sock.probes[0].PI_CONSOLE_LISTEN, "unix:/run/pd/c.sock", "the probe reads the deployment's env"); +}); + +test("--link with one operator token: audit first, then the mint, then the link with the code after # and the page as next", async () => { + const h = harness({ tokens: [{ name: "alice", role: "operator" }, { name: "watcher", role: "viewer" }] }); + const r = await consoleCommand(["runs", "--link"], h.deps); + assert.equal(r.minted, true); + assert.deepEqual(h.order, ["probe", "guarded", "read", "audit:ok", "mint", "openLink"], "a link is opened through the private page, never handed to the opener"); + assert.deepEqual(h.rows, [{ actor: { name: "rob", role: "panel", session: null }, action: "login-code", target: "alice", outcome: "ok", ticket: "0123456789ab" }]); + const link = `http://127.0.0.1:4790/login/code#${CODE}&next=/runs`; + assert.ok(text(h).includes(`sign-in link for "alice" (it works once, within 60 seconds): ${link}`), text(h)); + assert.deepEqual(h.opened, [link], "the local browser gets the link itself"); + assert.match(text(h), /handed to your browser$/m, "darwin: no snap or Flatpak note"); + const linux = harness(); + linux.deps.platform = "linux"; + await consoleCommand(["--link"], linux.deps); + assert.match(text(linux), /handed to your browser \(a snap or Flatpak browser cannot read the page in \/tmp and shows it as not found: open the link above instead\)/); + assert.ok(!text(h).includes(SHA), "the token's hash is never shown"); + assert.ok(!JSON.stringify(h.rows).includes(CODE), "the audit row never holds the code"); + assert.ok(!link.split("#")[0].includes("?"), "the code is never in a query string"); + + const run = harness(); + await consoleCommand(["gh-acme-7", "--link"], run.deps); + assert.ok(text(run).includes(`login/code#${CODE}&next=/run?id=gh-acme-7`)); + const home = harness(); + await consoleCommand(["--link"], home.deps); + assert.match(text(home), new RegExp(`login/code#${CODE}$`, "m"), "no page: no next, the overview"); +}); + +test("--link chooses among operator tokens: a name given, one alone, several by select, none refused with the add hint", async () => { + const many = [{ name: "alice", role: "operator" }, { name: "bob", role: "operator" }, { name: "watcher", role: "viewer" }]; + const picked = harness({ tokens: many, select: () => "bob" }); + await consoleCommand(["--link"], picked.deps); + assert.deepEqual(picked.selects[0][1], ["alice", "bob"], "only operators are offered"); + assert.equal(picked.rows[0].target, "bob"); + + const cancelled = harness({ tokens: many, select: () => undefined }); + const c = await consoleCommand(["--link"], cancelled.deps); + assert.equal(c.refused, "cancelled"); + assert.ok(!cancelled.order.includes("mint") && !cancelled.order.some((o) => o.startsWith("audit"))); + + const named = harness({ tokens: many }); + await consoleCommand(["--link", "bob"], named.deps); + assert.equal(named.rows[0].target, "bob"); + assert.equal(named.selects.length, 0); + + for (const [opts, words, why] of [ + [{ tokens: [{ name: "watcher", role: "viewer" }] }, ["--link"], /no operator token; add one in the operator's own shell with pi-dispatch-console token add --name --role operator/], + [{ tokensRefusal: "there is no console tokens file at /dep/console-tokens.json" }, ["--link"], /no console tokens file.*token add/], + [{ tokens: many }, ["--link", "watcher"], /a viewer token, and the panel signs in operators only/], + [{ tokens: many }, ["--link", "ghost"], /no token named "ghost"/], + [{ tokens: many }, ["--link"], /2 operator tokens; name one/], + ]) { + const h = harness(opts); + const r = await consoleCommand(words, h.deps); + assert.equal(r.refused, "link", words.join(" ")); + assert.match(text(h), why); + assert.ok(!h.order.includes("mint"), "nothing minted"); + } +}); + +test("--link: an audit row that cannot be appended means no code; a refused mint is recorded as failed", async () => { + const h = harness({ auditFails: true }); + const r = await consoleCommand(["--link"], h.deps); + assert.equal(r.refused, "link"); + assert.deepEqual(h.order, ["probe", "guarded", "read", "audit:ok"], "the mint is never reached"); + assert.match(text(h), /audit log could not be written \(ENOSPC\), so no code was made/); + assert.deepEqual(h.opened, []); + + const m = harness({ mintRefusal: "the login-code directory /dep/console-login-codes can be reached by other accounts" }); + await consoleCommand(["--link"], m.deps); + assert.deepEqual(m.rows.map((row) => [row.outcome, row.ticket]), [["ok", "0123456789ab"], ["failed", "0123456789ab"]], "one ticket ties the failed row to its ok row"); + assert.match(text(m), /can be reached by other accounts/); +}); + +test("--link refuses for a stopped console or an address it cannot name; through a socket it names the tunnel's end", async () => { + const stopped = harness({ state: "not-running" }); + assert.equal((await consoleCommand(["--link"], stopped.deps)).refused, "link"); + assert.match(text(stopped), /not running, and a code expires 60 seconds/); + assert.deepEqual(stopped.order, ["probe"]); + const ephemeral = harness({ state: "unknown", env: { PI_CONSOLE_LISTEN: "127.0.0.1:0" } }); + assert.equal((await consoleCommand(["--link"], ephemeral.deps)).refused, "link"); + const sock = harness({ env: { PI_CONSOLE_LISTEN: "unix:/run/pd/c.sock" } }); + await consoleCommand(["audit", "--link"], sock.deps); + assert.ok(text(sock).includes(`http://localhost:4790/login/code#${CODE}&next=/audit`)); + assert.deepEqual(sock.opened, []); + const unknown = harness({ state: "unknown" }); + assert.equal((await consoleCommand(["--link"], unknown.deps)).refused, "link", "only a console found running gets a link"); + assert.match(text(unknown), /could not be confirmed running/); + assert.deepEqual(unknown.order, ["probe"]); +}); + +test("--link mints only into a drop the write guard holds, and only in pi's terminal UI (issue #708, review round 1)", async () => { + for (const [opts, why] of [ + [{ guarded: false }, /guards no login-code directory to mint into/], + [{ tokensPath: null }, /does not see this deployment's tokens file/], + [{ mode: "rpc" }, /rpc mode here, where a notice goes to another program/], + [{ mode: "json" }, /json mode/], + [{ unsetMode: true }, /an unknown mode here/], + ]) { + const h = harness(opts); + if (opts.unsetMode) delete h.deps.mode; // a pi too old to say its mode + const r = await consoleCommand(["--link"], h.deps); + assert.equal(r.refused, "link", JSON.stringify(opts)); + assert.match(text(h), why); + assert.ok(!h.order.includes("read") && !h.order.includes("mint") && !h.order.some((o) => o.startsWith("audit")), "nothing read, audited or minted"); + } + const rpc = harness({ mode: "rpc" }); + await consoleCommand(["runs"], rpc.deps); + assert.match(text(rpc), /^http:\/\/127\.0\.0\.1:4790\/runs\nthe console: running/, "the address and the state are fine in rpc mode"); +}); + +test("the browser: opened on a local session for a TCP listener, never with --no-open or off a local session", async () => { + const local = harness(); + assert.equal((await consoleCommand(["config"], local.deps)).opened, true); + assert.deepEqual(local.opened, ["http://127.0.0.1:4790/config"]); + assert.match(text(local), /handed to your browser$/m); + + const noOpen = harness(); + const r = await consoleCommand(["config", "--no-open"], noOpen.deps); + assert.equal(r.opened, false); + assert.deepEqual(noOpen.opened, []); + assert.ok(text(noOpen).includes("http://127.0.0.1:4790/config"), "the URL is printed all the same"); + + const remote = harness({ local: false }); + await consoleCommand(["config"], remote.deps); + assert.deepEqual(remote.opened, []); + assert.match(text(remote), /not opened: this is not a local session/); + + const anyHost = harness({ env: { PI_CONSOLE_LISTEN: "0.0.0.0:4790", PI_CONSOLE_BIND_PUBLIC: "1" } }); + await consoleCommand([], anyHost.deps); + assert.deepEqual(anyHost.opened, [], " is no address to open"); + assert.match(text(anyHost), /http:\/\/:4790\//); +}); + +test("the default local-session check reads the session's env: SSH prints only", async () => { + const h = harness(); + delete h.deps.isLocal; + h.deps.sessionEnv = { SSH_CONNECTION: "10.0.0.2 5000 10.0.0.1 22" }; + await consoleCommand([], h.deps); + assert.deepEqual(h.opened, []); + h.deps.sessionEnv = {}; + await consoleCommand([], h.deps); + assert.deepEqual(h.opened, ["http://127.0.0.1:4790/"], "darwin with no SSH variable is local"); +}); + +test("the probe is the worker's one rule: through its request seam only loopback or the socket is ever contacted", async () => { + // No `probe` fake here: the command's default, `@edgehero/pi-dispatch/console-probe`, the probe doctor uses too. + const seen = []; + const request = (opts, onResponse) => { + seen.push(opts); + const req = new EventEmitter(); + req.destroy = () => {}; + req.end = () => setImmediate(() => { + const res = new EventEmitter(); + res.statusCode = 200; + res.resume = () => {}; + onResponse(res); + }); + return req; + }; + const cases = [ + [{}, "running"], + [{ PI_CONSOLE_LISTEN: "[::1]:4791" }, "running"], + [{ PI_CONSOLE_LISTEN: "0.0.0.0:4790", PI_CONSOLE_BIND_PUBLIC: "1" }, "running"], + [{ PI_CONSOLE_LISTEN: "unix:/run/pd/c.sock" }, "running"], + [{ PI_CONSOLE_LISTEN: "192.168.1.5:4790", PI_CONSOLE_BIND_PUBLIC: "1" }, "unknown"], + [{ PI_CONSOLE_LISTEN: "127.0.0.1:0" }, "unknown"], + [{ PI_CONSOLE_LISTEN: "nonsense" }, "unknown"], + ]; + for (const [env, want] of cases) { + const h = harness({ env }); + delete h.deps.probe; + h.deps.request = request; + const r = await consoleCommand([], h.deps); + assert.equal(r.state, want, JSON.stringify(env)); + } + assert.equal(seen.length, 4, "the three unknowns contacted nothing"); + for (const o of seen) { + assert.ok(o.socketPath === "/run/pd/c.sock" || ["127.0.0.1", "::1"].includes(o.host), `contacted ${o.host ?? o.socketPath}`); + assert.equal(o.path, "/healthz"); + assert.equal(o.headers.host, "127.0.0.1"); + } +}); + +test("probeLine: running, not running with the reason and both ways to start it, unknown with why", () => { + assert.equal(probeLine({ state: "running" }), "running"); + assert.equal(probeLine({ state: "not-running", reason: "nothing listens there" }), "not running (nothing listens there): start it with pi-dispatch-console serve, or keep it running with pi-dispatch service install --console (docs/console.md)"); + assert.match(probeLine({ state: "unknown", reason: "port 0" }), /not known: port 0/); +}); diff --git a/admin/test/console-login-codes.test.mjs b/admin/test/console-login-codes.test.mjs index 327f4694..91d78567 100644 --- a/admin/test/console-login-codes.test.mjs +++ b/admin/test/console-login-codes.test.mjs @@ -169,7 +169,10 @@ test("the drop directory is refused when it is a symlink, not a directory, or re fs.mkdirSync(dir, { mode: 0o755 }); fs.chmodSync(dir, 0o755); - assert.match(mintLoginCode({ dir, name: "rob", now, random: counter() }).refusal, /reached by other accounts \(mode 755\); run `chmod 700 /); + // Not "chmod 700" (issue #708): a directory others could write may already hold a planted code, so it must go. + const loose = mintLoginCode({ dir, name: "rob", now, random: counter() }).refusal; + assert.match(loose, /reached by other accounts \(mode 755\), so what it holds cannot be trusted; remove the directory \(`rm -r /); + assert.doesNotMatch(loose, /chmod/); assert.match(consumeLoginCode({ dir, code, now }).refusal, /mode 755/); fs.chmodSync(dir, 0o701); assert.match(mintLoginCode({ dir, name: "rob", now, random: counter() }).refusal, /mode 701/); @@ -194,3 +197,34 @@ test("mint creates its file exclusively: a link planted at the code's path after assert.match(res.refusal, /could not be written .*EEXIST/); assert.equal(fs.readFileSync(target, "utf8"), "untouched\n", "the planted link's target was not written"); }); + +test("consume refuses and removes a code file this minter did not write: loose mode, another owner, a too-long life (issue #708)", { skip: !posix }, () => { + const now = clock(); + const plant = (dir, code, { mode = 0o600, expiresAt = new Date(now() + 30000).toISOString() } = {}) => { + const file = join(dir, `${sha(code)}.json`); + fs.writeFileSync(file, JSON.stringify({ name: "rob", expiresAt })); + fs.chmodSync(file, mode); + return file; + }; + const { dir } = setup(); + assert.ok(mintLoginCode({ dir, name: "rob", now, random: counter() }).code, "the drop exists, 0700"); + for (const [i, mode] of [0o644, 0o640, 0o604, 0o660].entries()) { + const code = String.fromCharCode(97 + i).repeat(43); + const file = plant(dir, code, { mode }); + assert.match(consumeLoginCode({ dir, code, now }).refusal, /not valid/, mode.toString(8)); + assert.equal(fs.existsSync(file), false, `a ${mode.toString(8)} file is removed`); + } + const owned = "o".repeat(43); + const ownedFile = plant(dir, owned); + assert.match(consumeLoginCode({ dir, code: owned, now, uid: process.geteuid() + 1 }).refusal, /not valid/, "another account's file"); + assert.equal(fs.existsSync(ownedFile), false); + const forever = "f".repeat(43); + plant(dir, forever, { expiresAt: "9999-01-01T00:00:00.000Z" }); + assert.match(consumeLoginCode({ dir, code: forever, now }).refusal, /not valid/, "an expiry past now + ttl"); + const skewed = "s".repeat(43); + plant(dir, skewed, { expiresAt: new Date(now() + 63000).toISOString() }); + assert.deepEqual(consumeLoginCode({ dir, code: skewed, now }), { name: "rob" }, "a few seconds past the ttl is a clock step"); + const fine = "g".repeat(43); + plant(dir, fine); + assert.deepEqual(consumeLoginCode({ dir, code: fine, now }), { name: "rob" }, "a 0600 file of this account within the ttl"); +}); diff --git a/admin/test/console-login-next.test.mjs b/admin/test/console-login-next.test.mjs new file mode 100644 index 00000000..06d06de9 --- /dev/null +++ b/admin/test/console-login-next.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { PAGES, returnPathOf } from "../src/console/pages.mjs"; +import { CONSOLE_SCRIPT } from "../src/console/script.mjs"; + +// Issue #708: a sign-in link may name the page to land on (`login/code#&next=/runs`). The value comes from a link +// anyone could have written, so it must never send the browser anywhere but this console's own pages. + +test("returnPathOf accepts the console's own pages with a query their parser takes, and nothing else", () => { + for (const ok of ["/", "/runs", "/fleet", "/config", "/parked", "/audit", "/insights?range=mtd", "/run?id=repeat:t1:1760000000000"]) assert.equal(returnPathOf(ok), ok, ok); + const refused = [ + "//evil.example", "//evil.example/runs", "https://evil.example/", "javascript:alert(1)", "/\\evil.example", "\\\\evil.example", + "/login", "/login/code", "/static/console.js", "/api/whoami", "/healthz", "/nope", "runs", "", + "/runs?evil=1", "/run", "/run?id=", "/insights?range=1y", "/fleet?x=1", "/runs#frag", "/runs\n", "/r\u0000uns", + "/" + "a".repeat(2000), null, 7, {}, + ]; + for (const bad of refused) assert.equal(returnPathOf(bad), null, JSON.stringify(bad)); +}); + +/** Run the console script on a fake login-code page with fragment `hash`; the server answers `answer`. */ +async function signIn(hash, answer) { + const posted = []; + const replaced = []; + const el = () => ({ addEventListener() {}, textContent: "", getAttribute: () => null, querySelectorAll: () => [] }); + const document = { + body: { getAttribute: (k) => (k === "data-page" ? "login-code" : null) }, + getElementById: (id) => (id === "notice" ? el() : null), + querySelectorAll: () => [], + querySelector: () => null, + addEventListener() {}, + }; + const location = { hash, pathname: "/login/code", replace: (u) => replaced.push(u), assign: (u) => replaced.push(u) }; + const history = { replaceState: () => (location.hash = "") }; + const fetch = async (path, init) => { + posted.push([path, JSON.parse(init.body)]); + return { ok: true, status: 200, json: async () => answer }; + }; + new Function("document", "location", "history", "fetch", "URLSearchParams", "window", CONSOLE_SCRIPT)(document, location, history, fetch, URLSearchParams, {}); + for (let i = 0; i < 10 && replaced.length === 0; i += 1) await new Promise((r) => setImmediate(r)); + return { posted, replaced }; +} + +test("the script posts the code and next, and lands only where both the server and its own check agree", async () => { + const code = "k".repeat(43); + const plain = await signIn(`#${code}`, { ok: true, next: "/" }); + assert.deepEqual(plain.posted, [["/login/code", { code }]], "no next: only the code"); + assert.deepEqual(plain.replaced, ["/"]); + + const runs = await signIn(`#${code}&next=/runs`, { ok: true, next: "/runs" }); + assert.deepEqual(runs.posted[0][1], { code, next: "/runs" }); + assert.deepEqual(runs.replaced, ["/runs"]); + + const run = await signIn(`#${code}&next=/run?id=gh-1`, { ok: true, next: "/run?id=gh-1" }); + assert.deepEqual(run.posted[0][1].next, "/run?id=gh-1"); + assert.deepEqual(run.replaced, ["/run?id=gh-1"]); + + // Whatever the server answered, a destination off this console's pages is the overview. + for (const evil of ["//evil.example", "https://evil.example/", "/\\evil.example", "/login", "/nope", "/runs#x", 7, null]) { + const r = await signIn(`#${code}&next=/runs`, { ok: true, next: evil }); + assert.deepEqual(r.replaced, ["/"], JSON.stringify(evil)); + } +}); + +test("the script's page list is PAGES' paths, so the two checks name the same pages", () => { + assert.ok(CONSOLE_SCRIPT.includes(`var PAGE_PATHS = ${JSON.stringify(Object.values(PAGES).map((p) => p.path))};`)); +}); diff --git a/admin/test/console-open-link.test.mjs b/admin/test/console-open-link.test.mjs new file mode 100644 index 00000000..99a92fa5 --- /dev/null +++ b/admin/test/console-open-link.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { tempDir } from "./helpers/temp-dir.mjs"; +import { OPEN_DIR_PREFIX, openLinkViaFile, redirectPage } from "../src/console/open-link.mjs"; + +// Issue #708, review round 1: a sign-in link is opened through a private redirect page, so the opener's argv (readable +// by every local account while it runs) holds a file path and never the code. + +const posix = process.platform !== "win32"; +const LINK = `http://127.0.0.1:4790/login/code#${"C".repeat(43)}&next=/run?id=gh-1`; + +test("the opener gets a file: URL of a 0600 page in a 0700 directory; the page sends the browser on, fragment kept", { skip: !posix }, () => { + const base = tempDir("pd-open-"); + const opened = []; + const timers = []; + const r = openLinkViaFile(LINK, { open: (u) => opened.push(u), tmpdir: base, setTimer: (fn, ms) => (timers.push([fn, ms]), { unref() {} }) }); + assert.equal(r.opened, true); + assert.equal(opened.length, 1); + assert.match(opened[0], /^file:\/\//); + assert.ok(!opened[0].includes("C".repeat(43)) && !opened[0].includes("login/code"), "no code, no link, in the opener's argument"); + const page = fileURLToPath(opened[0]); + assert.equal(page, r.page); + assert.equal(fs.statSync(page).mode & 0o777, 0o600); + assert.equal(fs.statSync(join(page, "..")).mode & 0o777, 0o700); + assert.ok(join(page, "..").startsWith(join(base, OPEN_DIR_PREFIX))); + const html = fs.readFileSync(page, "utf8"); + assert.ok(html.includes(`location.replace(${JSON.stringify(LINK)});`), html); + assert.match(html, /name="referrer" content="no-referrer"/); + // Removed after the delay, by the timer the page set. + assert.equal(timers.length, 1); + assert.equal(timers[0][1], 60000); + timers[0][0](); + assert.equal(fs.existsSync(join(page, "..")), false); +}); + +test("a page an earlier run left is swept once it is older than keepMs; a younger one and a stranger's file are not", { skip: !posix }, () => { + const base = tempDir("pd-open-"); + const left = join(base, `${OPEN_DIR_PREFIX}old`); + fs.mkdirSync(left, { mode: 0o700 }); + fs.writeFileSync(join(left, "open.html"), "x"); + fs.writeFileSync(join(base, "other.txt"), "keep"); + const t = fs.statSync(left).mtimeMs; + const quiet = { open: () => {}, tmpdir: base, setTimer: () => ({ unref() {} }) }; + // A second link within the minute (another session's): the first page is still being read, so it stays. + const second = openLinkViaFile(LINK, { ...quiet, now: () => t + 59000 }); + assert.equal(fs.existsSync(left), true, "younger than keepMs: kept"); + assert.equal(fs.existsSync(second.page), true); + openLinkViaFile(LINK, { ...quiet, now: () => t + 61000 }); + assert.equal(fs.existsSync(left), false, "older than keepMs: swept"); + assert.equal(fs.readFileSync(join(base, "other.txt"), "utf8"), "keep"); +}); + +test("a page that cannot be made is refused and nothing is opened", () => { + const opened = []; + const failing = { ...fs, readdirSync: () => [], mkdtempSync: () => { const e = new Error("no"); e.code = "EACCES"; throw e; } }; + const r = openLinkViaFile(LINK, { open: (u) => opened.push(u), fs: failing, tmpdir: "/nowhere" }); + assert.match(r.refusal, /could not be made \(EACCES\)/); + assert.deepEqual(opened, []); +}); + +test("redirectPage escapes what could end the script", () => { + const html = redirectPage("http://127.0.0.1:4790/login/code#a