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 }); +});