#!/usr/bin/env bash # shellcheck disable=SC2154 # $uid / $user come from the container environment ## safe-paths.sh — sourced by entrypoint.sh and entrypoint-fpm.sh. Not executed directly. ## ## /home/$user is the customer's bind-mounted site directory: the customer can ## create anything in it, including symlinks, from SFTP/FTP, cron, or PHP. The ## entrypoints run as root, and root following a customer symlink is a ## privilege escalation: `touch`/`chown`/`>` on logs/php-fpm/error.log -> ## /etc/shadow hands the customer /etc/shadow (container root). ## ## Rules the entrypoints follow: ## * /home/$user itself is the bind-mount point and must be a real directory. ## * logs/ is WHP-owned and apache / php-fpm open files in it as root, so it ## must be a real directory and every symlink under it is removed + logged. ## * Files and dirs are CREATED as the customer (as_user), so a symlink or a ## swapped path can only reach what the customer could reach anyway. ## * Ownership changes use `chown -h` / `chown -R` (GNU default -P: never ## follows symlinks, lchown()s them), never a dereferencing chown. ## * Customer files are READ as the customer (crontab), never as root. # as_user CMD... : run CMD as the customer uid/gid (no root, no caps). as_user() { if command -v setpriv >/dev/null 2>&1; then setpriv --reuid="$uid" --regid="$uid" --init-groups -- "$@" else runuser -u "$user" -- "$@" fi } # die MSG : refuse to start. Only the customer who planted the path is hurt, # which is preferred to root following it. die() { echo "entrypoint: FATAL: $* - refusing to start" >&2 exit 1 } # drop_symlink PATH : if PATH is a symlink, unlink it AS THE CUSTOMER (rm on # the final component never touches the link target, and if a parent dir is # swapped for a symlink mid-way the customer can only delete what they already # could) and log it. Fails closed: still a symlink afterwards -> refuse to start. drop_symlink() { if [ -L "$1" ]; then echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removing" >&2 as_user rm -f -- "$1" [ -L "$1" ] && die "could not remove symlink $1" fi return 0 } # secure_home : run once, right after adduser, before anything else touches # /home/$user. secure_home() { local h="/home/$user" if [ -L "$h" ] || [ ! -d "$h" ]; then die "$h is not a real directory (symlink or missing)" fi # The mount point itself: owned by the customer so as_user can create in it. chown -h "$user:$user" "$h" drop_symlink "$h/logs" # public_html may legitimately be a symlink (release-dir deploys); it is left # alone and every root-side operation on it runs as the customer instead. if [ -d "$h/logs" ]; then # As the customer: find without -L never follows symlinks, -delete unlinks # the link itself, and a raced parent swap can only reach what the customer # could already delete. Errors are not fatal HERE: require_safe_logs # re-checks immediately before the daemons start and fails closed. as_user find "$h/logs" -xdev -type l \ -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi } # user_mkdir DIR... : create directories as the customer. user_mkdir() { as_user mkdir -p -- "$@" } # user_touch FILE : create FILE as the customer (a symlink there is dropped # first, fail-closed). Root never touches/chowns it: if it already exists # root-owned (left by an older image) the touch fails quietly and the # entrypoint's chown -R, which never follows symlinks, fixes the owner. Must end # up a regular file, or refuse to start. user_touch() { drop_symlink "$1" as_user touch -- "$1" 2>/dev/null || true [ -f "$1" ] && [ ! -L "$1" ] || die "$1 is not a regular file" } # require_safe_logs DIR... : fail-closed gate, run immediately before starting # the daemons that open files under /home/$user/logs AS ROOT (httpd, php-fpm). # Each DIR must be a real directory, and nothing under logs/ may be anything # but a regular file or directory (no symlink, fifo, socket, device). require_safe_logs() { local d bad for d in "/home/$user/logs" "$@"; do [ -d "$d" ] && [ ! -L "$d" ] || die "$d is not a real directory" done bad=$(find "/home/$user/logs" -xdev ! -type f ! -type d -print -quit 2>&1) [ -z "$bad" ] || die "unsafe entry under /home/$user/logs: $bad" return 0 } # load_user_crontab : install /home/$user/crontab, read AS THE CUSTOMER. Root # reading it (`crontab -u $user /home/$user/crontab`) would follow a symlink to # any root-readable file and install it where the customer can `crontab -l` it. load_user_crontab() { local f="/home/$user/crontab" if [ ! -e "$f" ] && [ ! -L "$f" ]; then as_user sh -c 'printf "%s\n" "# User crontab for $1" "# Add your cron jobs here" "# Example: */5 * * * * /home/$1/scripts/my-script.sh" > "$2"' sh "$user" "$f" fi # No temp file: root writing a predictable /tmp path is the same bug class. local content if content=$(as_user cat -- "$f" 2>/dev/null); then printf '%s\n' "$content" | crontab -u "$user" - else echo "entrypoint: WARNING: $f not readable as $user - user crontab not loaded" >&2 fi } # open_dir_traversal : chmod 755 the home dir, then add o+x to every directory # lacking it (apache/nginx run as their own user and must traverse). Run AS THE # CUSTOMER: `find -type d` without -L skips symlinks, but chmod would follow a # dir swapped for a symlink between find and chmod; as the customer that can # only reach what the customer already owns. open_dir_traversal() { chmod 755 "/home/$user" as_user find "/home/$user" -type d ! -perm -o=x -exec chmod o+x {} + 2>/dev/null || true }