From 2a1228b40ec0af1fdf3fe5c3a1ff48fc78ffb437 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 26 Jun 2026 23:41:18 +0000 Subject: [PATCH] Jail the control-panel terminal to the customer's home (chroot) SECURITY: the browser terminal ran an unconfined login shell as the account's system user, so customers could browse the whole host (/var/www, other tenants, /etc). Run it inside a jailkit chroot instead: a vetted, sudo-scoped launcher (/usr/local/sbin/ladill-jailsh) enters a private mount namespace, bind-mounts ONLY that user's home into /home/jail, and chroots as the user. They can no longer see anything outside their own home. Provisioning committed in deployment/ (setup-terminal-jail.sh + ladill-jailsh) so it is reproducible. Co-Authored-By: Claude Opus 4.8 --- .../RunHostingTerminalWorkerCommand.php | 26 +++++++++++- config/hosting.php | 5 +++ deployment/ladill-jailsh | 25 ++++++++++++ deployment/setup-terminal-jail.sh | 40 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100755 deployment/ladill-jailsh create mode 100755 deployment/setup-terminal-jail.sh diff --git a/app/Console/Commands/RunHostingTerminalWorkerCommand.php b/app/Console/Commands/RunHostingTerminalWorkerCommand.php index 8c13e22..a13cff6 100644 --- a/app/Console/Commands/RunHostingTerminalWorkerCommand.php +++ b/app/Console/Commands/RunHostingTerminalWorkerCommand.php @@ -131,7 +131,10 @@ class RunHostingTerminalWorkerCommand extends Command $launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows); if ($this->shouldUseLocalExecution($account->node)) { - return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand)); + // Confine the customer to a jailkit chroot (only their own home is + // visible) via the vetted, sudo-scoped launcher. SECURITY: never run + // an unjailed login shell here — it exposes the whole host filesystem. + return new LocalPtyShellSession($this->buildJailedLaunchCommand($account, $relativePath)); } $ssh = $provider->connect($account->node); @@ -145,6 +148,27 @@ class RunHostingTerminalWorkerCommand extends Command return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows); } + /** + * Local jailed shell: run the customer inside the jailkit chroot + * (config('hosting.terminal_jail_launcher')) as their own user. The launcher + * derives uid/gid, binds only that user's home into the jail, and chroots — + * so the customer can never see the host filesystem (/var/www, other homes, + * /etc, secrets). Invoked via sudo -n, scoped in /etc/sudoers.d/ladill-jailsh + * because the terminal worker runs as www-data. + */ + private function buildJailedLaunchCommand(HostingAccount $account, string $relativePath): string + { + $launcher = (string) config('hosting.terminal_jail_launcher', '/usr/local/sbin/ladill-jailsh'); + $rel = $relativePath !== '' ? $relativePath : '/public_html'; + + return implode(' ', [ + 'sudo', '-n', + escapeshellarg($launcher), + escapeshellarg($account->username), + escapeshellarg($rel), + ]); + } + private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string { $homeDirectory = "/home/{$account->username}"; diff --git a/config/hosting.php b/config/hosting.php index 67bfd4e..5c3c5ec 100644 --- a/config/hosting.php +++ b/config/hosting.php @@ -1,6 +1,11 @@ env('HOSTING_TERMINAL_JAIL_LAUNCHER', '/usr/local/sbin/ladill-jailsh'), + /* |-------------------------------------------------------------------------- | Central developer access diff --git a/deployment/ladill-jailsh b/deployment/ladill-jailsh new file mode 100755 index 0000000..1f1d86e --- /dev/null +++ b/deployment/ladill-jailsh @@ -0,0 +1,25 @@ +#!/bin/bash +# Ladill jailed shell for the hosting browser terminal. Confines a hosting +# account to /home/jail with only its own home bind-mounted (private mount ns). +# Usage: ladill-jailsh [relpath] +set -euo pipefail +JAIL=/home/jail +U=${1:-} +REL=${2:-/public_html} +[ -n "$U" ] || { echo 'usage: ladill-jailsh [relpath]'; exit 2; } +# resolve + validate the target user (must be a real hosting account) +UID_=$(id -u "$U" 2>/dev/null || true) +GID_=$(id -g "$U" 2>/dev/null || true) +[ -n "$UID_" ] && [ "$UID_" -ge 1000 ] || { echo 'refusing: not a hosting user'; exit 1; } +HOMEDIR=/home/$U +[ -d "$HOMEDIR" ] || { echo 'no home directory'; exit 1; } +case "$REL" in *..*) REL=/public_html;; esac +mkdir -p "$JAIL/home/$U" +# re-exec into a private mount namespace so the bind is per-session +if [ -z "${LADILL_JAIL_NS:-}" ]; then + exec env LADILL_JAIL_NS=1 unshare --mount --propagation private -- "$0" "$U" "$REL" +fi +mount --bind "$HOMEDIR" "$JAIL/home/$U" +export HOME="/home/$U" USER="$U" LOGNAME="$U" SHELL=/bin/bash PATH=/usr/bin:/bin +export TERM="${TERM:-xterm-256color}" +exec /usr/sbin/chroot --userspec="$UID_:$GID_" "$JAIL" /bin/bash -l -c "cd '/home/$U$REL' 2>/dev/null || cd ~; exec /bin/bash -l" diff --git a/deployment/setup-terminal-jail.sh b/deployment/setup-terminal-jail.sh new file mode 100755 index 0000000..ee56789 --- /dev/null +++ b/deployment/setup-terminal-jail.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Provision the jailkit chroot used by the control-panel browser terminal so the +# customer shell is confined to its own home (cannot see /var/www, other tenants, +# /etc, or secrets). Idempotent; run as root on each hosting node. +set -Eeuo pipefail + +JAIL=/home/jail + +echo "==> Installing jailkit" +export DEBIAN_FRONTEND=noninteractive +command -v jk_init >/dev/null 2>&1 || apt-get install -y jailkit + +echo "==> Building jail tree at $JAIL" +jk_init -j "$JAIL" uidbasics netbasics basicshell interactiveshell extendedshell editors netutils terminfo || true +jk_cp -j "$JAIL" /usr/bin/id /usr/bin/whoami /usr/bin/find /usr/bin/grep /usr/bin/less \ + /usr/bin/head /usr/bin/tail /usr/bin/du /usr/bin/df /usr/bin/wc /usr/bin/clear /usr/bin/sed /usr/bin/awk || true + +# Terminal definitions (xterm*) for a usable TTY. +mkdir -p "$JAIL/usr/share/terminfo" +cp -rn /usr/share/terminfo/x "$JAIL/usr/share/terminfo/" 2>/dev/null || true + +# Non-listable jail /home (each session bind-mounts only its own home here). +install -d -o root -g root -m 711 "$JAIL/home" +install -d -m 1777 "$JAIL/tmp" + +echo "==> Syncing hosting users into the jail passwd/group (for name resolution)" +while IFS=: read -r name _ uid gid _ home shell; do + [ "$uid" -ge 1000 ] 2>/dev/null || continue + [ -d "/home/$name" ] || continue + grep -q "^$name:" "$JAIL/etc/passwd" || getent passwd "$name" >> "$JAIL/etc/passwd" + grep -q "^$name:" "$JAIL/etc/group" || getent group "$name" >> "$JAIL/etc/group" +done < <(getent passwd) + +echo "==> Installing the jailed-shell launcher + scoped sudoers" +install -o root -g root -m 755 "$(dirname "$0")/ladill-jailsh" /usr/local/sbin/ladill-jailsh +echo 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/ladill-jailsh *' > /etc/sudoers.d/ladill-jailsh +chmod 440 /etc/sudoers.d/ladill-jailsh +visudo -cf /etc/sudoers.d/ladill-jailsh + +echo "==> Done. Customer terminals now run jailed in $JAIL."