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:
1 parent
eaf616eb30
commit
0ffccac4b8
3 files changed
+143
-54
No files matched your search
+25
-30
@@ -14,16 +14,18 @@ export FPM_LISTEN=${FPM_LISTEN:-9000}
|
|||||||
|
|
||||||
adduser -u $uid $user
|
adduser -u $uid $user
|
||||||
|
|
||||||
mkdir -p /home/$user/public_html
|
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
|
||||||
mkdir -p /home/$user/logs/php-fpm
|
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 the pool's error_log (php_admin_value[error_log] in
|
||||||
# create-php-config.sh) BEFORE the chown -R below, and chown it explicitly.
|
# create-php-config.sh) AS THE CUSTOMER, before php-fpm starts. It used to be
|
||||||
# It used to be `touch`ed as root at the very end, after the chown, so on a
|
# `touch`ed as root at the very end, after the chown -R, so on a site's first
|
||||||
# site's first start the pool (customer uid) could not open it and PHP errors
|
# start the pool (customer uid) could not open it and PHP errors fell back to
|
||||||
# fell back to FastCGI stderr -> shared-httpd's error log until the next
|
# FastCGI stderr -> shared-httpd's error log until the next restart's chown -R
|
||||||
# restart's chown -R fixed the owner.
|
# fixed the owner. user_touch never writes or chowns through a symlink.
|
||||||
touch /home/$user/logs/php-fpm/error.log
|
user_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
|
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/
|
mkdir -p /run/php-fpm/
|
||||||
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
/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
|
chown -R $user:$user /home/$user
|
||||||
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
|
# 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
|
# 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:
|
# 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
|
# 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
|
# files are never touched, so execute is never added and wp-config.php is
|
||||||
# never loosened.
|
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
|
||||||
chmod 755 /home/$user
|
# reach what the customer already owns.
|
||||||
find /home/$user -type d ! -perm -o=x -exec chmod o+x {} +
|
open_dir_traversal
|
||||||
|
|
||||||
if [[ $environment == 'DEV' ]]; then
|
if [[ $environment == 'DEV' ]]; then
|
||||||
echo "Starting Dev Deployment (FPM-only mode)"
|
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
|
if ! command -v microdnf &> /dev/null; then
|
||||||
echo "microdnf not found, installing with dnf..."
|
echo "microdnf not found, installing with dnf..."
|
||||||
dnf install -y microdnf && dnf clean all
|
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 "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||||
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||||
mysql -e "FLUSH PRIVILEGES;"
|
mysql -e "FLUSH PRIVILEGES;"
|
||||||
echo "# User crontab for $user" > /home/$user/crontab
|
# Written AS THE CUSTOMER (as_user tee), never by root through a customer path.
|
||||||
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
drop_symlink /home/$user/crontab
|
||||||
chown $user:$user /home/$user/crontab
|
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
|
||||||
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
| as_user tee /home/$user/crontab >/dev/null
|
||||||
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
|
||||||
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
| as_user tee /home/$user/mysql_creds
|
||||||
cat /home/$user/mysql_creds
|
|
||||||
fi
|
fi
|
||||||
/usr/bin/memcached -d -u $user
|
/usr/bin/memcached -d -u $user
|
||||||
fi
|
fi
|
||||||
@@ -84,16 +86,9 @@ if [[ $environment == 'PROD' ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set up user crontab
|
# Set up + load the user crontab, created and read as the customer
|
||||||
if [ ! -f /home/$user/crontab ]; then
|
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
|
||||||
echo "# User crontab for $user" > /home/$user/crontab
|
load_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
|
|
||||||
|
|
||||||
/usr/sbin/crond
|
/usr/sbin/crond
|
||||||
|
|
||||||
|
|||||||
+20
-24
@@ -10,8 +10,11 @@ fi
|
|||||||
|
|
||||||
adduser -u $uid $user
|
adduser -u $uid $user
|
||||||
|
|
||||||
mkdir -p /home/$user/public_html
|
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
|
||||||
mkdir -p /home/$user/logs/{apache,php-fpm}
|
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
|
mv /var/log/httpd /var/log/httpd.bak
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ fi
|
|||||||
|
|
||||||
/usr/sbin/httpd -k start
|
/usr/sbin/httpd -k start
|
||||||
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
/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
|
chown -R $user:$user /home/$user
|
||||||
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
|
# 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
|
# 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:
|
# 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
|
# 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
|
# files are never touched, so execute is never added and wp-config.php is
|
||||||
# never loosened.
|
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
|
||||||
chmod 755 /home/$user
|
# reach what the customer already owns.
|
||||||
find /home/$user -type d ! -perm -o=x -exec chmod o+x {} +
|
open_dir_traversal
|
||||||
|
|
||||||
if [[ $environment == 'DEV' ]]; then
|
if [[ $environment == 'DEV' ]]; then
|
||||||
echo "Starting Dev Deployment"
|
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
|
# Ensure microdnf is available for installing MariaDB and memcached in DEV mode
|
||||||
if ! command -v microdnf &> /dev/null; then
|
if ! command -v microdnf &> /dev/null; then
|
||||||
echo "microdnf not found, installing with dnf..."
|
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 "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||||
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||||
mysql -e "FLUSH PRIVILEGES;"
|
mysql -e "FLUSH PRIVILEGES;"
|
||||||
# Create user crontab with MySQL backup job
|
# Create user crontab with MySQL backup job. Written AS THE CUSTOMER
|
||||||
echo "# User crontab for $user" > /home/$user/crontab
|
# (as_user tee), never by root through a customer path.
|
||||||
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
drop_symlink /home/$user/crontab
|
||||||
chown $user:$user /home/$user/crontab
|
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
|
||||||
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
| as_user tee /home/$user/crontab >/dev/null
|
||||||
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
|
||||||
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
| as_user tee /home/$user/mysql_creds
|
||||||
cat /home/$user/mysql_creds
|
|
||||||
|
|
||||||
fi
|
fi
|
||||||
/usr/bin/memcached -d -u $user
|
/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
|
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set up user crontab
|
# Set up + load the user crontab, created and read as the customer
|
||||||
if [ ! -f /home/$user/crontab ]; then
|
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
|
||||||
echo "# User crontab for $user" > /home/$user/crontab
|
load_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
|
|
||||||
|
|
||||||
/usr/sbin/crond
|
/usr/sbin/crond
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user