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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-20 15:49:18 -07:00
1 parent eaf616eb30
commit 0ffccac4b8
3 files changed
+143 -54

No files matched your search

+25 -30
View File
@@ -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