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

+98
View File
@@ -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
}