From 0b5a7c6cd4aae12301f667bc686d7925e0514ad3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 16:25:57 +0000 Subject: [PATCH] fix(install): refuse a root install instead of publishing a broken moshcode public/install.sh is what moshcoding.com/install.sh serves, so this is the script everyone actually runs. Every path in it comes from $HOME. Escalated, that is /root: the CLI lands in /root/.moshcode and the wrapper in /root/.local/bin, mode 0700. link_system_bin then makes it worse than a no-op. Because $MOSHCODE_BIN is not on root's PATH but /usr/local/bin is and is writable, it publishes /usr/local/bin/moshcode -> /root/.local/bin/moshcode so `moshcode` resolves on PATH for every user on the box and executes for none of them. The install prints "Install complete" and exits 0. The first sign of trouble arrives later, on an unrelated command: $ moshcode install secrets zsh: permission denied: moshcode This is easy to walk into: `dns enable` needs root and tells you to re-run under sudo, and `moshcode update` self-updates by re-running this installer (upgrade.mjs selfSpec) -- so an escalated update quietly reinstalls the CLI into root's home. Refuse that case before any work, and name the user who would be locked out. A bare root shell (containers, CI images, root-only VPS) has no SUDO_USER and is a legitimate way to install, so only the escalated-from-a-real-user case is refused, and MOSHCODE_ALLOW_ROOT overrides even that. `remove` is deliberately left unguarded, since cleaning up an existing root install is exactly when running as root is right. The tests drive the real script with `id` shadowed on PATH, so the root branch runs without root. They assert the refusal lands before detect_os -- the first step of run_install -- rather than merely that it happens, because a guard that fires after the install has started is not a guard. Co-Authored-By: Claude Opus 5 (1M context) --- public/install.sh | 42 +++++++++++ tests/install-root-guard.test.mjs | 120 ++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/install-root-guard.test.mjs diff --git a/public/install.sh b/public/install.sh index 6b55a3a..20e4883 100644 --- a/public/install.sh +++ b/public/install.sh @@ -28,6 +28,15 @@ # MOSHCODE_REF=ref git ref (default: latest release, else main) # MOSHCODE_USE_SYSTEM_NODE=1 keep an existing system Node 20+ instead of # installing the current LTS through mise +# MOSHCODE_ALLOW_ROOT=1 install as root anyway (see below) +# +# Do not install this with sudo. Every path below is derived from $HOME, which +# sudo sets to /root — so the CLI lands in /root/.moshcode and the wrapper in +# /root/.local/bin, mode 0700, unreadable by the user who ran the command. It +# is worse than a no-op: link_system_bin then points /usr/local/bin/moshcode at +# that unreadable wrapper, so `moshcode` resolves for everyone and runs for +# nobody. The install still prints "Install complete", and the failure only +# shows up later as "permission denied". This script refuses that case. # # Re-running this script updates an existing install in place. @@ -86,6 +95,38 @@ ok() { printf '%s ✓%s %s\n' "$GREEN" "$RESET" "$*"; } warn() { printf '%s !%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } fail() { printf '%s ✗%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; } +# `sudo curl … | sh` installs for the wrong user and says nothing. HOME is +# /root, so MOSHCODE_HOME and MOSHCODE_BIN land in a 0700 directory, and +# link_system_bin then publishes /usr/local/bin/moshcode -> that unreadable +# wrapper. The result resolves on PATH for every user and executes for none: +# +# $ moshcode install secrets +# zsh: permission denied: moshcode +# +# A bare root shell (containers, CI images, root-only VPS) has no SUDO_USER and +# is a legitimate way to install, so only the escalated-from-a-real-user case is +# refused; MOSHCODE_ALLOW_ROOT overrides even that. +check_not_sudo() { + [ "$(id -u 2>/dev/null || echo 0)" = "0" ] || return 0 + [ -n "${SUDO_USER:-}" ] || return 0 + if [ -n "${MOSHCODE_ALLOW_ROOT:-}" ]; then + info "MOSHCODE_ALLOW_ROOT set — installing as root into $MOSHCODE_HOME" + return 0 + fi + fail "don't install moshcode with sudo. + + Every path here comes from \$HOME, which sudo has set to $HOME, so this + would install for root and leave $SUDO_USER with a moshcode on PATH that + it cannot execute. + + Run it as yourself instead: + curl -fsSL $INSTALL_URL | sh + + For a deliberate system-wide install, name the paths explicitly: + sudo MOSHCODE_ALLOW_ROOT=1 MOSHCODE_HOME=/opt/moshcode \\ + MOSHCODE_BIN=/usr/local/bin sh -c 'curl -fsSL $INSTALL_URL | sh'" +} + detect_os() { case "$(uname -s)" in Linux) OS=linux ;; @@ -305,6 +346,7 @@ run_remove() { run_install() { printf '\n%smoshcoding — moshcode installer%s\n' "$GREEN" "$RESET" printf ' home: %s\n bin: %s\n\n' "$MOSHCODE_HOME" "$MOSHCODE_BIN" + check_not_sudo detect_os; ok "OS: $OS" mkdir -p "$MOSHCODE_HOME" "$MOSHCODE_BIN" ensure_node diff --git a/tests/install-root-guard.test.mjs b/tests/install-root-guard.test.mjs new file mode 100644 index 0000000..a8571c5 --- /dev/null +++ b/tests/install-root-guard.test.mjs @@ -0,0 +1,120 @@ +// public/install.sh is what `curl -fsSL https://moshcoding.com/install.sh | sh` +// actually runs. Every path in it comes from $HOME, so running it under sudo +// installs into /root/.moshcode with the wrapper at /root/.local/bin — mode +// 0700. link_system_bin then points /usr/local/bin/moshcode at that wrapper, +// which is the worst of both worlds: `moshcode` resolves on PATH for every +// user and executes for none. The install still prints "Install complete", so +// the first sign of trouble is `permission denied` on a later, unrelated +// command. +// +// These tests run the real script with `id` shadowed on PATH, so its root +// branch is exercised without root. The refusal must land before any work. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const INSTALL_SH = fileURLToPath(new URL("../public/install.sh", import.meta.url)); +const scratch = []; + +/** A PATH dir where `id -u` reports `uid`; curl/mise are tripwires. */ +function fakeBin(uid) { + const dir = mkdtempSync(join(tmpdir(), "moshcoding-guard-")); + scratch.push(dir); + const bin = join(dir, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "id"), `#!/bin/sh\n[ "$1" = "-u" ] && echo ${uid} && exit 0\nexec /usr/bin/id "$@"\n`); + for (const tool of ["curl", "mise"]) { + writeFileSync(join(bin, tool), `#!/bin/sh\necho "REACHED_${tool.toUpperCase()}" >&2\nexit 99\n`); + } + for (const f of ["id", "curl", "mise"]) chmodSync(join(bin, f), 0o755); + return bin; +} + +function runInstall(uid, env = {}) { + const bin = fakeBin(uid); + const home = mkdtempSync(join(tmpdir(), "moshcoding-home-")); + scratch.push(home); + try { + return { + code: 0, + output: execFileSync("sh", [INSTALL_SH, "install"], { + env: { PATH: `${bin}:${process.env.PATH}`, HOME: home, NO_COLOR: "1", ...env }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }), + }; + } catch (error) { + return { code: error.status, output: `${error.stdout ?? ""}${error.stderr ?? ""}` }; + } +} + +test("a sudo install is refused, and refused before anything happens", () => { + const { code, output } = runInstall(0, { SUDO_USER: "anthony" }); + + assert.notEqual(code, 0, "a sudo install must not report success"); + assert.match(output, /don't install moshcode with sudo/); + // detect_os is the very first step of run_install. If its line appears, the + // guard ran too late to be a guard. + assert.doesNotMatch(output, /✓ OS:/); + assert.doesNotMatch(output, /REACHED_CURL|REACHED_MISE/); + assert.doesNotMatch(output, /Install complete/); +}); + +test("the refusal names the locked-out user and both ways forward", () => { + const { output } = runInstall(0, { SUDO_USER: "anthony" }); + + assert.match(output, /anthony/, "it should name the user who would be locked out"); + assert.match(output, /curl -fsSL https:\/\/moshcoding\.com\/install\.sh \| sh/); + assert.match(output, /MOSHCODE_ALLOW_ROOT=1/); +}); + +test("a bare root shell still installs — only sudo-from-a-user is refused", () => { + // Containers, CI images and root-only VPS boxes have no SUDO_USER. Refusing + // there would break a legitimate install for no reason. + const { output } = runInstall(0); + + assert.doesNotMatch(output, /don't install moshcode with sudo/); + assert.match(output, /✓ OS:/, "it should get past the guard and start work"); +}); + +test("MOSHCODE_ALLOW_ROOT overrides the refusal", () => { + const { output } = runInstall(0, { SUDO_USER: "anthony", MOSHCODE_ALLOW_ROOT: "1" }); + + assert.doesNotMatch(output, /don't install moshcode with sudo/); + assert.match(output, /installing as root/); + assert.match(output, /✓ OS:/); +}); + +test("a normal user is never affected, even with SUDO_USER set", () => { + // A plain shell inherits SUDO_USER after any earlier sudo call, so the uid + // check has to be the thing that decides. + const { output } = runInstall(1000, { SUDO_USER: "anthony" }); + + assert.doesNotMatch(output, /don't install moshcode with sudo/); + assert.match(output, /✓ OS:/); +}); + +test("remove is deliberately left unguarded", () => { + // Cleaning up an existing root install is the one case where running this + // as root is the right thing to do. + const source = readFileSync(INSTALL_SH, "utf8"); + const runRemove = source.slice(source.indexOf("run_remove()"), source.indexOf("run_install()")); + + assert.doesNotMatch(runRemove, /check_not_sudo/); +}); + +test("the sudo trap is documented in the header, not only in the error", () => { + const source = readFileSync(INSTALL_SH, "utf8"); + const header = source.slice(0, source.indexOf("set -eu")); + + assert.match(header, /MOSHCODE_ALLOW_ROOT/); + assert.match(header, /sudo/); +}); + +test.after(() => { + for (const dir of scratch) rmSync(dir, { recursive: true, force: true }); +});