From 14761597bb0e2e85b2cbc5492ef617251046a7c8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 12:33:29 -0700 Subject: [PATCH 1/7] fix(entrypoint): stop chmod -R 755 on the customer docroot every start entrypoint.sh and entrypoint-fpm.sh ran `chmod -R 755 /home/$user` on every container start. /home/$user is the bind-mounted customer site directory, so every file (wp-config.php included) became world-readable and executable, and any tighter permissions the customer set were reverted on each restart. The line dates from the initial commit (95b9397) and was copied into the FPM entrypoint (c781678); neither gives a reason. The legitimate need is that Apache (bundled httpd, or shared-httpd reading /mnt/users/...) runs as `apache`, not the customer, and must be able to traverse directories to serve static files. Keep the recursive chown. Replace the recursive chmod with: - chmod 755 on the docroot directory itself (matches entrypoint-litespeed.sh) - o+x on directories that lack it (find -type d ! -perm -o=x) Regular files are never touched: execute is never added, and wp-config.php or any other customer-restricted file is never loosened. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint-fpm.sh | 12 +++++++++++- scripts/entrypoint.sh | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index ae2d445..e9a0c7b 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -27,7 +27,17 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_ mkdir -p /run/php-fpm/ /usr/sbin/php-fpm -y /etc/php-fpm.conf chown -R $user:$user /home/$user -chmod -R 755 /home/$user +# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's +# bind-mounted site directory; a recursive 755 on every start made every file +# (wp-config.php included) world-readable AND executable, and reverted any +# tighter permissions the customer had set. Apache (shared-httpd, or the +# bundled httpd) runs as `apache`, not the customer, so it only needs to +# traverse directories to serve static files. Normalise that and nothing else: +# open the docroot itself, and add o+x to directories that lack it. Regular +# files are never touched, so execute is never added and wp-config.php is +# never loosened. +chmod 755 /home/$user +find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + if [[ $environment == 'DEV' ]]; then echo "Starting Dev Deployment (FPM-only mode)" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 77acc7e..4790eb5 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -37,7 +37,17 @@ fi /usr/sbin/httpd -k start /usr/sbin/php-fpm -y /etc/php-fpm.conf chown -R $user:$user /home/$user -chmod -R 755 /home/$user +# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's +# bind-mounted site directory; a recursive 755 on every start made every file +# (wp-config.php included) world-readable AND executable, and reverted any +# tighter permissions the customer had set. Apache (shared-httpd, or the +# bundled httpd) runs as `apache`, not the customer, so it only needs to +# traverse directories to serve static files. Normalise that and nothing else: +# open the docroot itself, and add o+x to directories that lack it. Regular +# files are never touched, so execute is never added and wp-config.php is +# never loosened. +chmod 755 /home/$user +find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + if [[ $environment == 'DEV' ]]; then echo "Starting Dev Deployment" -- 2.52.0 From eaf616eb30411297f3caa1940e8130d2882d073a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 15:44:14 -0700 Subject: [PATCH 2/7] fix(entrypoint): graceful docker stop + cac-fpm error_log owner on first start Graceful stop (cac, cac-fpm, shared-httpd, and form-sender which runs cac): entrypoint.sh, entrypoint-fpm.sh and entrypoint-shared-httpd.sh end with bash as PID 1 tailing logs. PID 1 only receives signals it has a handler for, so docker stop's SIGTERM was dropped; docker waited the full 10s and SIGKILLed apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT and stop each daemon they started via the new scripts/stop-services.sh: httpd graceful-stop (WINCH), php-fpm QUIT, crond TERM, each with a bounded wait and a TERM/KILL fallback, budgets summing to < 10s, then exit 0. The log tail is backgrounded + `wait`ed (and shared-httpd's 60s rescan sleep likewise) so the trap runs immediately. Log streaming to docker logs is unchanged. cac-fpm error_log: entrypoint-fpm.sh `touch`ed logs/php-fpm/error.log as root AFTER its chown -R, so on a site's first start the pool (customer uid) could not open it and PHP errors went out as FastCGI stderr to shared-httpd's log until the next restart. The file is now created and chowned to the customer right after the logs dir is made, before php-fpm starts. Co-Authored-By: Claude Opus 5.5 --- Dockerfile.shared-httpd | 1 + scripts/entrypoint-fpm.sh | 32 ++++++++++++++++-- scripts/entrypoint-shared-httpd.sh | 31 ++++++++++++++--- scripts/entrypoint.sh | 27 ++++++++++++++- scripts/stop-services.sh | 54 ++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 scripts/stop-services.sh diff --git a/Dockerfile.shared-httpd b/Dockerfile.shared-httpd index 1c4dd3c..5e0c157 100644 --- a/Dockerfile.shared-httpd +++ b/Dockerfile.shared-httpd @@ -13,6 +13,7 @@ COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh +COPY ./scripts/stop-services.sh /scripts/stop-services.sh COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh RUN chmod +x /scripts/* diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index e9a0c7b..a378ed9 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -16,6 +16,14 @@ adduser -u $uid $user mkdir -p /home/$user/public_html mkdir -p /home/$user/logs/php-fpm +# Create the pool's error_log (php_admin_value[error_log] in +# create-php-config.sh) BEFORE the chown -R below, and chown it explicitly. +# It used to be `touch`ed as root at the very end, after the chown, so on a +# site's first start the pool (customer uid) could not open it and PHP errors +# fell back to FastCGI stderr -> shared-httpd's error log until the next +# restart's chown -R fixed the owner. +touch /home/$user/logs/php-fpm/error.log +chown $user:$user /home/$user/logs/php-fpm/error.log ln -sf /home/$user/logs/php-fpm /var/log/php-fpm @@ -89,8 +97,26 @@ crontab -u $user /home/$user/crontab /usr/sbin/crond -# Tail PHP-FPM logs (becomes PID 1 process) -touch /home/$user/logs/php-fpm/error.log -tail -f /home/$user/logs/php-fpm/* +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed php-fpm mid-request (exit 137). +# php-fpm QUIT = graceful (workers finish their current request), then crond. +source /scripts/stop-services.sh +TAIL_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint: received stop signal, shutting down" + stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 6 'php-fpm: master' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Tail PHP-FPM logs to docker logs. Backgrounded + `wait` (instead of a +# foreground tail) so bash can run the trap as soon as a signal arrives. +tail -f /home/$user/logs/php-fpm/* & +TAIL_PID=$! +wait "$TAIL_PID" exit 0 diff --git a/scripts/entrypoint-shared-httpd.sh b/scripts/entrypoint-shared-httpd.sh index 8f54dbb..cf36cc8 100755 --- a/scripts/entrypoint-shared-httpd.sh +++ b/scripts/entrypoint-shared-httpd.sh @@ -59,11 +59,30 @@ fi # Start cron for log rotation /usr/sbin/crond -# Tail Apache logs (becomes PID 1 process) +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed apache mid-request (exit 137), for +# every site on the host at once. graceful-stop (WINCH) lets in-flight requests +# finish; after 7s it falls back to TERM. Timeouts sum to < 10s so this +# completes inside docker's default stop timeout. +source /scripts/stop-services.sh +TAIL_PID="" +SLEEP_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint-shared-httpd: received stop signal, shutting down" + stop_service httpd /etc/httpd/run/httpd.pid WINCH 7 '^/usr/sbin/httpd' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Tail Apache logs to docker logs. # Use a loop to pick up new log files as vhosts are added. # tail -f only watches files that exist at start time. touch /var/log/httpd/error_log -TAIL_PID="" while true; do LOG_FILES=$(find /var/log/httpd/ -name '*.log' -o -name '*_log' 2>/dev/null | sort) if [ -n "$TAIL_PID" ]; then @@ -72,6 +91,10 @@ while true; do fi tail -f $LOG_FILES & TAIL_PID=$! - # Re-check for new log files every 60 seconds - sleep 60 + # Re-check for new log files every 60 seconds. Background + `wait` (not a + # foreground sleep) so a stop signal runs the trap immediately instead of + # after up to 60s. + sleep 60 & + SLEEP_PID=$! + wait "$SLEEP_PID" done diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 4790eb5..ddb36f5 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -100,7 +100,32 @@ fi crontab -u $user /home/$user/crontab /usr/sbin/crond -tail -f /var/log/httpd/* + +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed apache and php-fpm mid-request +# (exit 137). Stop apache first (graceful-stop: finish in-flight requests, +# accept no new ones), then php-fpm (QUIT: workers finish their current +# request), then crond. Timeouts sum to < 10s so this completes inside +# docker's default stop timeout. +source /scripts/stop-services.sh +TAIL_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint: received stop signal, shutting down" + stop_service httpd /etc/httpd/run/httpd.pid WINCH 4 '^/usr/sbin/httpd' + stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 3 'php-fpm: master' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Stream apache logs to docker logs. Backgrounded + `wait` (instead of a +# foreground tail) so bash can run the trap as soon as a signal arrives. +tail -f /var/log/httpd/* & +TAIL_PID=$! +wait "$TAIL_PID" exit 0 diff --git a/scripts/stop-services.sh b/scripts/stop-services.sh new file mode 100644 index 0000000..37cac71 --- /dev/null +++ b/scripts/stop-services.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +## stop-services.sh — sourced by the bash PID-1 entrypoints (entrypoint.sh, +## entrypoint-fpm.sh, entrypoint-shared-httpd.sh). Not executed directly. +## +## Why this exists: those entrypoints end with bash as PID 1 tailing logs. +## The kernel never delivers a signal to PID 1 unless PID 1 installed a handler +## for it, so `docker stop`'s SIGTERM was silently dropped. Docker waited the +## full stop timeout (10s) and then SIGKILLed the whole container, killing +## apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT +## and call stop_service for each daemon they started, then exit 0. +## +## Budget: docker's default stop timeout is 10s. Callers pass per-service +## timeouts that sum to < 10s so the graceful path always completes before +## docker gives up and SIGKILLs. + +# A pid counts as alive if it exists and is not a zombie. bash (PID 1) reaps +# the daemons it adopted, but between exit and reap a zombie still answers +# `kill -0`, so check /proc state too. +_svc_alive() { + [ -n "$1" ] || return 1 + kill -0 "$1" 2>/dev/null || return 1 + ! grep -qs '^State:[[:space:]]*Z' "/proc/$1/status" +} + +# stop_service NAME PIDFILE GRACEFUL_SIGNAL GRACE_SECONDS [PGREP_PATTERN] +# Sends GRACEFUL_SIGNAL, waits up to GRACE_SECONDS for the process to exit, +# then sends TERM and waits 1s more, then KILL. Pid comes from PIDFILE, or +# from `pgrep -o -f PGREP_PATTERN` when the pidfile is missing/stale. +stop_service() { + local name=$1 pidfile=$2 sig=$3 grace=$4 pat=${5:-} pid="" i + [ -r "$pidfile" ] && pid=$(tr -dc '0-9' < "$pidfile") + if ! _svc_alive "$pid" && [ -n "$pat" ]; then + pid=$(pgrep -o -f "$pat" 2>/dev/null) + fi + if ! _svc_alive "$pid"; then + echo "entrypoint: $name not running" + return 0 + fi + echo "entrypoint: stopping $name (pid $pid) with SIG$sig" + kill -s "$sig" "$pid" 2>/dev/null + for ((i = 0; i < grace * 10; i++)); do + _svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; } + sleep 0.1 + done + echo "entrypoint: $name still running after ${grace}s, sending SIGTERM" + kill -TERM "$pid" 2>/dev/null + for ((i = 0; i < 10; i++)); do + _svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; } + sleep 0.1 + done + echo "entrypoint: $name still running, sending SIGKILL" + kill -KILL "$pid" 2>/dev/null + return 0 +} -- 2.52.0 From 0ffccac4b8634f8f020fafcb898090f8bbd3c1fe Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 15:49:18 -0700 Subject: [PATCH 3/7] fix(entrypoint): root never follows customer symlinks under /home/$user /home/$user is the customer's bind mount; they can plant symlinks in it. The entrypoints ran as root and wrote/chowned through customer paths, so e.g. logs/php-fpm/error.log -> /etc/shadow (or a dangling crontab -> /etc/...) handed the customer that file, i.e. container root. New scripts/safe-paths.sh, used by entrypoint.sh and entrypoint-fpm.sh: - secure_home: refuse to start if /home/$user is not a real dir; logs/ must be a real dir and every symlink under logs/ is removed and logged (apache and php-fpm open those files as root at start). - dirs, the pool error_log and the default crontab are created AS THE CUSTOMER (setpriv, runuser fallback); a pre-existing root-owned error_log is fixed with chown -h (never dereferences). - the user crontab is read as the customer and piped to `crontab -u $user -` (root reading it could install any root-readable file where the customer can `crontab -l` it). DEV-mode crontab/mysql_creds are written via as_user tee. - the o+x directory pass (find -type d, no -L) runs as the customer, so a dir swapped for a symlink before chmod can only reach what the customer owns. - chown -R is unchanged: GNU default -P lchown()s symlinks, never follows. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint-fpm.sh | 55 ++++++++++------------ scripts/entrypoint.sh | 44 ++++++++---------- scripts/safe-paths.sh | 98 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 54 deletions(-) create mode 100644 scripts/safe-paths.sh diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index a378ed9..5da6075 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -14,16 +14,18 @@ export FPM_LISTEN=${FPM_LISTEN:-9000} adduser -u $uid $user -mkdir -p /home/$user/public_html -mkdir -p /home/$user/logs/php-fpm +# Root never follows customer symlinks under /home/$user: see safe-paths.sh. +source /scripts/safe-paths.sh +secure_home + +user_mkdir /home/$user/public_html /home/$user/logs/php-fpm # Create the pool's error_log (php_admin_value[error_log] in -# create-php-config.sh) BEFORE the chown -R below, and chown it explicitly. -# It used to be `touch`ed as root at the very end, after the chown, so on a -# site's first start the pool (customer uid) could not open it and PHP errors -# fell back to FastCGI stderr -> shared-httpd's error log until the next -# restart's chown -R fixed the owner. -touch /home/$user/logs/php-fpm/error.log -chown $user:$user /home/$user/logs/php-fpm/error.log +# create-php-config.sh) AS THE CUSTOMER, before php-fpm starts. It used to be +# `touch`ed as root at the very end, after the chown -R, so on a site's first +# start the pool (customer uid) could not open it and PHP errors fell back to +# FastCGI stderr -> shared-httpd's error log until the next restart's chown -R +# fixed the owner. user_touch never writes or chowns through a symlink. +user_touch /home/$user/logs/php-fpm/error.log ln -sf /home/$user/logs/php-fpm /var/log/php-fpm @@ -34,6 +36,7 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_ mkdir -p /run/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 # Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's # bind-mounted site directory; a recursive 755 on every start made every file @@ -43,13 +46,13 @@ chown -R $user:$user /home/$user # traverse directories to serve static files. Normalise that and nothing else: # open the docroot itself, and add o+x to directories that lack it. Regular # files are never touched, so execute is never added and wp-config.php is -# never loosened. -chmod 755 /home/$user -find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + +# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only +# reach what the customer already owns. +open_dir_traversal if [[ $environment == 'DEV' ]]; then echo "Starting Dev Deployment (FPM-only mode)" - mkdir -p /home/$user/_db_backups + user_mkdir /home/$user/_db_backups if ! command -v microdnf &> /dev/null; then echo "microdnf not found, installing with dnf..." dnf install -y microdnf && dnf clean all @@ -67,13 +70,12 @@ if [[ $environment == 'DEV' ]]; then mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';" mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;" mysql -e "FLUSH PRIVILEGES;" - echo "# User crontab for $user" > /home/$user/crontab - echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab - chown $user:$user /home/$user/crontab - echo "MySQL User: "$mysql_user > /home/$user/mysql_creds - echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds - echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds - cat /home/$user/mysql_creds + # Written AS THE CUSTOMER (as_user tee), never by root through a customer path. + drop_symlink /home/$user/crontab + printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \ + | as_user tee /home/$user/crontab >/dev/null + printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \ + | as_user tee /home/$user/mysql_creds fi /usr/bin/memcached -d -u $user fi @@ -84,16 +86,9 @@ if [[ $environment == 'PROD' ]]; then fi fi -# Set up user crontab -if [ ! -f /home/$user/crontab ]; then - echo "# User crontab for $user" > /home/$user/crontab - echo "# Add your cron jobs here" >> /home/$user/crontab - echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab - chown $user:$user /home/$user/crontab -fi - -# Load user crontab -crontab -u $user /home/$user/crontab +# Set up + load the user crontab, created and read as the customer +# (safe-paths.sh). Root reading/writing/chowning it followed symlinks. +load_user_crontab /usr/sbin/crond diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index ddb36f5..ed789c6 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -10,8 +10,11 @@ fi adduser -u $uid $user -mkdir -p /home/$user/public_html -mkdir -p /home/$user/logs/{apache,php-fpm} +# Root never follows customer symlinks under /home/$user: see safe-paths.sh. +source /scripts/safe-paths.sh +secure_home + +user_mkdir /home/$user/public_html /home/$user/logs/apache /home/$user/logs/php-fpm mv /var/log/httpd /var/log/httpd.bak @@ -36,6 +39,7 @@ fi /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. chown -R $user:$user /home/$user # Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's # bind-mounted site directory; a recursive 755 on every start made every file @@ -45,13 +49,13 @@ chown -R $user:$user /home/$user # traverse directories to serve static files. Normalise that and nothing else: # open the docroot itself, and add o+x to directories that lack it. Regular # files are never touched, so execute is never added and wp-config.php is -# never loosened. -chmod 755 /home/$user -find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + +# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only +# reach what the customer already owns. +open_dir_traversal if [[ $environment == 'DEV' ]]; then echo "Starting Dev Deployment" - mkdir -p /home/$user/_db_backups + user_mkdir /home/$user/_db_backups # Ensure microdnf is available for installing MariaDB and memcached in DEV mode if ! command -v microdnf &> /dev/null; then echo "microdnf not found, installing with dnf..." @@ -70,14 +74,13 @@ if [[ $environment == 'DEV' ]]; then mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';" mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;" mysql -e "FLUSH PRIVILEGES;" - # Create user crontab with MySQL backup job - echo "# User crontab for $user" > /home/$user/crontab - echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab - chown $user:$user /home/$user/crontab - echo "MySQL User: "$mysql_user > /home/$user/mysql_creds - echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds - echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds - cat /home/$user/mysql_creds + # Create user crontab with MySQL backup job. Written AS THE CUSTOMER + # (as_user tee), never by root through a customer path. + drop_symlink /home/$user/crontab + printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \ + | as_user tee /home/$user/crontab >/dev/null + printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \ + | as_user tee /home/$user/mysql_creds fi /usr/bin/memcached -d -u $user @@ -88,16 +91,9 @@ if [[ $environment == 'PROD' ]]; then sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini fi -# Set up user crontab -if [ ! -f /home/$user/crontab ]; then - echo "# User crontab for $user" > /home/$user/crontab - echo "# Add your cron jobs here" >> /home/$user/crontab - echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab - chown $user:$user /home/$user/crontab -fi - -# Load user crontab -crontab -u $user /home/$user/crontab +# Set up + load the user crontab, created and read as the customer +# (safe-paths.sh). Root reading/writing/chowning it followed symlinks. +load_user_crontab /usr/sbin/crond diff --git a/scripts/safe-paths.sh b/scripts/safe-paths.sh new file mode 100644 index 0000000..21cd16e --- /dev/null +++ b/scripts/safe-paths.sh @@ -0,0 +1,98 @@ +#!/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 +} + +# drop_symlink PATH : if PATH is a symlink, unlink it (rm on a symlink never +# touches the target) and log it. +drop_symlink() { + if [ -L "$1" ]; then + echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removed" >&2 + rm -f -- "$1" + fi +} + +# secure_home : run once, right after adduser, before anything else touches +# /home/$user. +secure_home() { + local h="/home/$user" d + if [ -L "$h" ] || [ ! -d "$h" ]; then + echo "entrypoint: FATAL: $h is not a real directory (symlink or missing) - refusing to start" >&2 + exit 1 + 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 + # find without -L never follows symlinks (start path verified real above). + find "$h/logs" -xdev -type l -print0 2>/dev/null | while IFS= read -r -d '' d; do + drop_symlink "$d" + done + fi +} + +# user_mkdir DIR... : create directories as the customer. +user_mkdir() { + as_user mkdir -p -- "$@" +} + +# user_touch FILE : create FILE as the customer. If it already exists owned by +# someone else (root-created by an older image), fix ownership with chown -h, +# which never follows a symlink; a symlink there is dropped first. +user_touch() { + drop_symlink "$1" + as_user touch -- "$1" 2>/dev/null || chown -h "$user:$user" -- "$1" 2>/dev/null || true +} + +# 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 +} -- 2.52.0 From 94a73ea8ab81dcbee8ab3c03945d5c027d545272 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 15:52:01 -0700 Subject: [PATCH 4/7] fix(safe-paths): unlink symlinks and create files as the customer, never as root drop_symlink and the logs/ symlink sweep now unlink AS THE CUSTOMER, and user_touch no longer falls back to a root chown -h (a parent dir swapped for a symlink between check and use could still redirect a root path operation). A root-owned leftover error_log is fixed by the existing chown -R, which never follows symlinks. Every operation these helpers perform is now safe even if the customer races it. Co-Authored-By: Claude Opus 5.5 --- scripts/safe-paths.sh | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/scripts/safe-paths.sh b/scripts/safe-paths.sh index 21cd16e..8c17017 100644 --- a/scripts/safe-paths.sh +++ b/scripts/safe-paths.sh @@ -27,19 +27,20 @@ as_user() { fi } -# drop_symlink PATH : if PATH is a symlink, unlink it (rm on a symlink never -# touches the target) and log it. +# 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. drop_symlink() { if [ -L "$1" ]; then - echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removed" >&2 - rm -f -- "$1" + 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 fi } # secure_home : run once, right after adduser, before anything else touches # /home/$user. secure_home() { - local h="/home/$user" d + 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 @@ -49,11 +50,10 @@ secure_home() { 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 - # find without -L never follows symlinks (start path verified real above). - find "$h/logs" -xdev -type l -print0 2>/dev/null | while IFS= read -r -d '' d; do - drop_symlink "$d" - done + 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 \ + -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi } @@ -62,12 +62,13 @@ user_mkdir() { as_user mkdir -p -- "$@" } -# user_touch FILE : create FILE as the customer. If it already exists owned by -# someone else (root-created by an older image), fix ownership with chown -h, -# which never follows a symlink; a symlink there is dropped first. +# 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. user_touch() { drop_symlink "$1" - as_user touch -- "$1" 2>/dev/null || chown -h "$user:$user" -- "$1" 2>/dev/null || true + as_user touch -- "$1" 2>/dev/null || true } # load_user_crontab : install /home/$user/crontab, read AS THE CUSTOMER. Root -- 2.52.0 From 02d93f4d1af89828a8328a612353a8eb2b3aed6e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:04:21 -0700 Subject: [PATCH 5/7] 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 -- 2.52.0 From 86a88c87db2614cc4b9780f62b2ab896eacd0560 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:06:41 -0700 Subject: [PATCH 6/7] fix(safe-paths): unlink planted symlinks as the customer, then fail closed Root rm -f in drop_symlink could be redirected by a parent-directory swap to delete arbitrary files as root. drop_symlink and the logs/ symlink sweep now unlink AS THE CUSTOMER (can never delete anything the customer couldn't), and drop_symlink still refuses to start if the path is a symlink afterwards; the pre-daemon logs/ gate re-checks and fails closed. Co-Authored-By: Claude Opus 5.5 --- scripts/safe-paths.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/safe-paths.sh b/scripts/safe-paths.sh index ceab00e..f453d30 100644 --- a/scripts/safe-paths.sh +++ b/scripts/safe-paths.sh @@ -34,13 +34,14 @@ die() { 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 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 - rm -f -- "$1" + as_user rm -f -- "$1" [ -L "$1" ] && die "could not remove symlink $1" fi return 0 @@ -59,9 +60,11 @@ secure_home() { # 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 - # 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 \ + # 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 } -- 2.52.0 From 74648ba34bce4fde3d2f6c3f298e1ab179069721 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:31:56 -0700 Subject: [PATCH 7/7] fix(security): deny config/VCS dotfiles over HTTP on standalone cac and cac-litespeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on .148: standalone cac served /.env with its contents, and cac-litespeed served /.env even at mode 0640 (OLS runs as the customer uid there). shared-httpd and shared-ols already deny these via WHP's drop-ins; the standalone tiers never got them. - cac: new configs/whp-security.conf in /etc/httpd/conf.d — the same named FilesMatch + DirectoryMatch list as WHP's shared-httpd-security.conf, re-rooted to /home. - cac-litespeed: the same `context exp:` regex as WHP's shared-ols vhconf template, added to the vhost template. Named list (.user.ini .env* .git .svn .hg .bzr .ht* .whp .site-builder), not a blanket dotfile deny, so /.well-known/ stays reachable. HTTP only: PHP still reads these files from disk. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 + configs/litespeed/site-template.tpl | 12 ++++++++++++ configs/whp-security.conf | 29 +++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 configs/whp-security.conf diff --git a/Dockerfile b/Dockerfile index 676bddc..e999c5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY ./configs/phpinfo.php /var/www/html/ COPY ./configs/mariadb.repo /etc/yum.repos.d/ COPY ./configs/index.php /var/www/html/ COPY ./configs/remote_ip.conf /etc/httpd/conf.d/ +COPY ./configs/whp-security.conf /etc/httpd/conf.d/ # Set up cron job in a single layer RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab diff --git a/configs/litespeed/site-template.tpl b/configs/litespeed/site-template.tpl index b508e54..5db442d 100644 --- a/configs/litespeed/site-template.tpl +++ b/configs/litespeed/site-template.tpl @@ -72,6 +72,18 @@ virtualHostConfig { addDefaultCharset off } + ## SECURITY: never serve config / VCS dotfiles over HTTP. Same named list as + ## WHP's shared-ols vhconf template (first `context exp:` there) and + ## shared-httpd's 000-whp-security.conf. Measured on .148: this tier served + ## /.env even at mode 0640, because OLS runs as the customer uid here. An + ## `exp:` regex context wins over the `context /` prefix context regardless + ## of order (measured on OLS 1.8.4 for shared-ols), so a customer .htaccess + ## cannot reopen it. Named, not blanket "^\.": /.well-known/ (ACME, + ## security.txt, app-site-association) must stay reachable. + context exp:^/(.*/)?\.(user\.ini|env|git|svn|hg|bzr|ht|whp|site-builder) { + allowBrowse 0 + } + rewrite { enable 1 autoLoadHtaccess 1 diff --git a/configs/whp-security.conf b/configs/whp-security.conf new file mode 100644 index 0000000..b492680 --- /dev/null +++ b/configs/whp-security.conf @@ -0,0 +1,29 @@ +## SECURITY: never serve config / VCS dotfiles over HTTP (standalone cac). +## +## Same named list as WHP's shared-httpd drop-in (web-files/configs/ +## shared-httpd-security.conf, 000-whp-security.conf on every shared-httpd host), +## re-rooted from /mnt/users to /home because here the docroot is +## /home//public_html. Standalone cac never got that drop-in, and measured +## on .148 it served /.env with contents. +## +## matches the FILENAME resolved from the filesystem path, not the +## request URL, so it cannot be side-stepped by URL-encoding. httpd applies +## sections (and .htaccess) first and / after +## them, so this deny wins over create-vhost.sh's `Require all granted`. +## +## A NAMED LIST, NOT A BLANKET "^\." DENY: /.well-known/ (ACME, security.txt, +## apple-app-site-association, assetlinks.json, payment-domain verification) +## must stay reachable. FilesMatch tests the final path component, and for +## /.well-known/acme-challenge/ that is , not a dotfile. +## +## HTTP only: PHP still reads .user.ini and .env off the filesystem as before. + + + Require all denied + + +## Directory form: reaches the files INSIDE .git/ etc. (e.g. /.git/config, +## whose own name is not a dotfile). + + Require all denied + -- 2.52.0