From 02d93f4d1af89828a8328a612353a8eb2b3aed6e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:04:21 -0700 Subject: [PATCH] fix(safe-paths): fail closed on planted symlinks before root daemons start drop_symlink unlinks as root (rm on the final component never follows the target and the customer cannot deny it) and refuses to start if the path is still a symlink. user_touch refuses unless it ends up a regular file. New require_safe_logs runs immediately before httpd / php-fpm start (they open files under /home/$user/logs as root): logs/, logs/apache, logs/php-fpm must be real directories and nothing under logs/ may be other than a regular file or directory, else the entrypoint exits 1 without starting any daemon. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint-fpm.sh | 3 +++ scripts/entrypoint.sh | 3 +++ scripts/safe-paths.sh | 49 +++++++++++++++++++++++++++++---------- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index 5da6075..36fa0af 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -35,6 +35,9 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_ /scripts/create-php-config.sh mkdir -p /run/php-fpm/ +# php-fpm opens its slowlog under /home/$user/logs as root: last fail-closed +# check immediately before it starts (safe-paths.sh). +require_safe_logs /home/$user/logs/php-fpm /usr/sbin/php-fpm -y /etc/php-fpm.conf # GNU chown -R defaults to -P: symlinks are lchown()ed, never followed. chown -R $user:$user /home/$user diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index ed789c6..96eee39 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -37,6 +37,9 @@ if [ -f /etc/httpd/conf.d/ssl.conf ]; then mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak fi +# httpd and php-fpm open their logs under /home/$user/logs as root: last +# fail-closed check immediately before they start (safe-paths.sh). +require_safe_logs /home/$user/logs/apache /home/$user/logs/php-fpm /usr/sbin/httpd -k start /usr/sbin/php-fpm -y /etc/php-fpm.conf # GNU chown -R defaults to -P: symlinks are lchown()ed, never followed. diff --git a/scripts/safe-paths.sh b/scripts/safe-paths.sh index 8c17017..ceab00e 100644 --- a/scripts/safe-paths.sh +++ b/scripts/safe-paths.sh @@ -27,14 +27,23 @@ as_user() { fi } -# drop_symlink PATH : if PATH is a symlink, unlink it and log it. The unlink -# runs AS THE CUSTOMER: if a parent dir is swapped for a symlink between the -# check and the rm, the customer can only unlink what they could already. +# 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 root: rm on the final +# component never touches the link target, and the customer cannot deny it) and +# log it. Fails closed: if it is 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" || echo "entrypoint: WARNING: could not remove symlink $1" >&2 + 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 @@ -42,17 +51,17 @@ drop_symlink() { secure_home() { local h="/home/$user" if [ -L "$h" ] || [ ! -d "$h" ]; then - echo "entrypoint: FATAL: $h is not a real directory (symlink or missing) - refusing to start" >&2 - exit 1 + 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" ] && [ ! -L "$h/logs" ]; then - # As the customer, find without -L never follows symlinks; -delete unlinks. - as_user find "$h/logs" -xdev -type l \ + if [ -d "$h/logs" ]; then + # find without -L never follows symlinks; -delete unlinks the link itself. + # Errors are not fatal HERE: require_safe_logs re-checks and fails closed. + find "$h/logs" -xdev -type l \ -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi } @@ -63,12 +72,28 @@ user_mkdir() { } # user_touch FILE : create FILE as the customer (a symlink there is dropped -# first). 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. +# 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