2026-10-20 15:49:18 -07:00
#!/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
}
2026-10-20 16:04:21 -07:00
# 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
}
2026-10-20 16:06:41 -07:00
# 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.
2026-10-20 15:49:18 -07:00
drop_symlink() {
if [ -L " $1 " ] ; then
2026-10-20 15:52:01 -07:00
echo "entrypoint: SECURITY: $1 was a symlink to ' $( readlink " $1 " ) ' - removing" >& 2
2026-10-20 16:06:41 -07:00
as_user rm -f -- " $1 "
2026-10-20 16:04:21 -07:00
[ -L " $1 " ] && die "could not remove symlink $1 "
2026-10-20 15:49:18 -07:00
fi
2026-10-20 16:04:21 -07:00
return 0
2026-10-20 15:49:18 -07:00
}
# secure_home : run once, right after adduser, before anything else touches
# /home/$user.
secure_home() {
2026-10-20 15:52:01 -07:00
local h = "/home/ $user "
2026-10-20 15:49:18 -07:00
if [ -L " $h " ] || [ ! -d " $h " ] ; then
2026-10-20 16:04:21 -07:00
die " $h is not a real directory (symlink or missing)"
2026-10-20 15:49:18 -07:00
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.
2026-10-20 16:04:21 -07:00
if [ -d " $h /logs" ] ; then
2026-10-20 16:06:41 -07:00
# 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 \
2026-10-20 15:52:01 -07:00
-printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >& 2 || true
2026-10-20 15:49:18 -07:00
fi
}
# user_mkdir DIR... : create directories as the customer.
user_mkdir() {
as_user mkdir -p -- " $@ "
}
2026-10-20 15:52:01 -07:00
# user_touch FILE : create FILE as the customer (a symlink there is dropped
2026-10-20 16:04:21 -07:00
# 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.
2026-10-20 15:49:18 -07:00
user_touch() {
drop_symlink " $1 "
2026-10-20 15:52:01 -07:00
as_user touch -- " $1 " 2>/dev/null || true
2026-10-20 16:04:21 -07:00
[ -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
2026-10-20 15:49:18 -07:00
}
# 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
}