Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 ;;
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions tests/install-root-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading