From 3ad03be0151a80d6c92ae0bf28e4c55d3df3a8a2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 12:34:38 -0700 Subject: [PATCH 1/5] fix(entrypoint): stop chmod -R 755 on the customer home every start entrypoint.sh ran `chmod -R 755 /home/$user` on every container start. /home/$user is the customer's bind-mounted directory, so every file (.env and other app secrets included) became world-readable and executable, and any tighter permissions the customer set were reverted on each restart. It dates from the initial implementation (2989cd5) with no stated reason; the same pattern was removed from cloud-apache-container. The legitimate need: nginx workers run as `nginx` (configs/nginx.conf) and serve /static/ via `alias /home/$user/app/public/`, so nginx must traverse directories. Keep the recursive chown; replace the recursive chmod with chmod 755 on /home/$user itself and o+x on directories lacking it. Regular files are never touched. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 109fbc7..509cb48 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -32,7 +32,16 @@ ln -s /home/$user/logs/nodejs /var/log/nodejs # Set ownership and permissions chown -R $user:$user /home/$user -chmod -R 755 /home/$user +# Do NOT `chmod -R 755` here. /home/$user is the customer's bind-mounted +# directory; a recursive 755 on every start made every file (.env, app +# secrets) world-readable AND executable, and reverted any tighter +# permissions the customer had set. nginx workers run as `nginx` (not the +# customer) and serve /static/ straight from /home/$user/app/public/, so they +# only need to traverse directories. Normalise that and nothing else: open the +# home dir itself (adduser -m creates it 0700 when it does not exist yet), and +# add o+x to directories that lack it. Regular files are never touched. +chmod 755 /home/$user +find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + # Start nginx nginx From b9c5a8d617878ec08fa638d2a5279346f61aa256 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 15:49:18 -0700 Subject: [PATCH 2/5] 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 entrypoint ran as root and created/copied/chowned through customer paths (the example-app copy, ecosystem.config.js generation + non-recursive chown, which dereferences). Now: refuse to start if /home/$user is not a real dir; logs/ must be a real dir and symlinks under it are removed and logged (nginx opens those files as root); app/logs dirs, the example copy and ecosystem.config.js are created AS THE CUSTOMER (setpriv, runuser fallback); the o+x directory pass runs as the customer. chown -R is unchanged (GNU default -P never follows symlinks). app/ itself may stay a symlink (release-dir deploys). Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint.sh | 53 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 509cb48..ad382ca 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -19,8 +19,38 @@ else echo "User $user already exists" fi -mkdir -p /home/$user/app -mkdir -p /home/$user/logs/{nginx,nodejs} +# ---- root never follows customer symlinks under /home/$user ---- +# /home/$user is the customer's bind mount: they can plant symlinks in it, and +# root touching/chowning/writing through one (e.g. logs/nginx -> /etc) gives +# them that target. logs/ is WHP-owned and nginx opens files in it as root, so +# symlinks there are removed and logged; everything under /home/$user is created/written AS THE CUSTOMER. +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() { + if [ -L "$1" ]; then + echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removed" >&2 + rm -f -- "$1" + fi +} +if [ -L /home/$user ] || [ ! -d /home/$user ]; then + echo "entrypoint: FATAL: /home/$user is not a real directory - refusing to start" >&2 + exit 1 +fi +chown -h $user:$user /home/$user +# app/ may legitimately be a symlink (releases/current deploys): it is left +# alone, and every root-side operation on it below runs as the customer. +drop_symlink /home/$user/logs +if [ -d /home/$user/logs ]; then + # nginx opens its logs here as root; find without -L never follows links. + find /home/$user/logs -xdev -type l -print0 2>/dev/null | while IFS= read -r -d '' l; do drop_symlink "$l"; done +fi + +as_user mkdir -p /home/$user/app /home/$user/logs/nginx /home/$user/logs/nodejs # Link log directories rm -rf /var/log/nginx @@ -30,7 +60,8 @@ ln -s /home/$user/logs/nodejs /var/log/nodejs # Configure nginx for reverse proxy /scripts/create-nginx-config.sh -# Set ownership and permissions +# Set ownership and permissions. GNU chown -R defaults to -P: symlinks are +# lchown()ed, never followed. chown -R $user:$user /home/$user # Do NOT `chmod -R 755` here. /home/$user is the customer's bind-mounted # directory; a recursive 755 on every start made every file (.env, app @@ -40,8 +71,11 @@ chown -R $user:$user /home/$user # only need to traverse directories. Normalise that and nothing else: open the # home dir itself (adduser -m creates it 0700 when it does not exist yet), and # add o+x to directories that lack it. Regular files are never touched. +# The find runs AS THE CUSTOMER: `-type d` without -L skips symlinks, and if a +# dir is swapped for a symlink before chmod runs, the customer can only reach +# what they already own. chmod 755 /home/$user -find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + +as_user find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + 2>/dev/null || true # Start nginx nginx @@ -68,15 +102,13 @@ fi # Create app directory if it doesn't exist if [ ! -d /home/$user/app ]; then echo "Creating app directory at /home/$user/app" - mkdir -p /home/$user/app - chown -R $user:$user /home/$user/app + as_user mkdir -p /home/$user/app fi # If app directory is empty, copy the simple-website example if [ -z "$(ls -A /home/$user/app)" ]; then echo "App directory is empty, copying simple-website example..." - cp -r /examples/simple-website/* /home/$user/app/ - chown -R $user:$user /home/$user/app + as_user cp -r /examples/simple-website/. /home/$user/app/ echo "Copied simple-website example to provide a working application" fi @@ -94,8 +126,9 @@ su -c "npm install" $user # Check if ecosystem.config.js exists, if not generate it if [ ! -f /home/$user/app/ecosystem.config.js ]; then echo "No ecosystem.config.js found, generating from package.json..." - /scripts/generate-ecosystem-config.sh "$user" "/home/$user/app" - chown $user:$user /home/$user/app/ecosystem.config.js + # As the customer: it reads their package.json and writes into their app dir. + drop_symlink /home/$user/app/ecosystem.config.js + as_user /scripts/generate-ecosystem-config.sh "$user" "/home/$user/app" fi # Mirror logs to docker logs in the background. From a84f05d41bef76561f5dd0881614bd6f4a0b45ff Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 15:52:01 -0700 Subject: [PATCH 3/5] fix(entrypoint): unlink planted symlinks as the customer, never as root Same race-safety as cac: drop_symlink and the logs/ symlink sweep run as the customer, so a parent dir swapped for a symlink between check and rm cannot redirect a root unlink. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index ad382ca..c05cc5a 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -31,10 +31,12 @@ as_user() { runuser -u "$user" -- "$@" fi } +# Unlink AS THE CUSTOMER, so a parent dir swapped for a symlink between the +# check and the rm can only reach what the customer 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 } if [ -L /home/$user ] || [ ! -d /home/$user ]; then @@ -45,9 +47,11 @@ chown -h $user:$user /home/$user # app/ may legitimately be a symlink (releases/current deploys): it is left # alone, and every root-side operation on it below runs as the customer. drop_symlink /home/$user/logs -if [ -d /home/$user/logs ]; then - # nginx opens its logs here as root; find without -L never follows links. - find /home/$user/logs -xdev -type l -print0 2>/dev/null | while IFS= read -r -d '' l; do drop_symlink "$l"; done +if [ -d /home/$user/logs ] && [ ! -L /home/$user/logs ]; then + # nginx opens its logs here as root. As the customer, find without -L never + # follows symlinks; -delete unlinks them. + as_user find /home/$user/logs -xdev -type l \ + -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi as_user mkdir -p /home/$user/app /home/$user/logs/nginx /home/$user/logs/nodejs From d400d5eb39cc7b28a0a7ee10919581f46af479bd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:04:21 -0700 Subject: [PATCH 4/5] fix(entrypoint): fail closed on planted symlinks before nginx starts drop_symlink unlinks as root (never follows the target) and refuses to start if the path is still a symlink. Immediately before nginx (which opens its logs under /home/$user/logs as root) starts, logs/, logs/nginx and logs/nodejs must be real directories and nothing under logs/ may be other than a regular file or directory, else the entrypoint exits 1. Co-Authored-By: Claude Opus 5.5 --- scripts/entrypoint.sh | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index c05cc5a..58e4317 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -31,26 +31,33 @@ as_user() { runuser -u "$user" -- "$@" fi } -# Unlink AS THE CUSTOMER, so a parent dir swapped for a symlink between the -# check and the rm can only reach what the customer could already. +# Fail closed: refusing to start only hurts the customer who planted the path, +# which is preferred to root following it. +die() { + echo "entrypoint: FATAL: $* - refusing to start" >&2 + exit 1 +} +# Unlink as root: rm on the final component never touches the link target and +# the customer cannot deny it. 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 } if [ -L /home/$user ] || [ ! -d /home/$user ]; then - echo "entrypoint: FATAL: /home/$user is not a real directory - refusing to start" >&2 - exit 1 + die "/home/$user is not a real directory" fi chown -h $user:$user /home/$user # app/ may legitimately be a symlink (releases/current deploys): it is left # alone, and every root-side operation on it below runs as the customer. drop_symlink /home/$user/logs -if [ -d /home/$user/logs ] && [ ! -L /home/$user/logs ]; then - # nginx opens its logs here as root. As the customer, find without -L never - # follows symlinks; -delete unlinks them. - as_user find /home/$user/logs -xdev -type l \ +if [ -d /home/$user/logs ]; then + # nginx opens its logs here as root. find without -L never follows symlinks; + # -delete unlinks the link itself. Re-checked fail-closed before nginx starts. + find /home/$user/logs -xdev -type l \ -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi @@ -81,6 +88,15 @@ chown -R $user:$user /home/$user chmod 755 /home/$user as_user find /home/$user -type d ! -perm -o=x -exec chmod o+x {} + 2>/dev/null || true +# nginx opens its logs under /home/$user/logs as root: last fail-closed check +# immediately before it starts. logs/, logs/nginx, logs/nodejs must be real +# dirs and nothing under logs/ may be other than a regular file or directory. +for d in /home/$user/logs /home/$user/logs/nginx /home/$user/logs/nodejs; 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" + # Start nginx nginx From 4064d9d6aa2e5998eb6e1ef6bfe8a32f5206957e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 16:06:41 -0700 Subject: [PATCH 5/5] fix(entrypoint): 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/entrypoint.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 58e4317..7e70b2d 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -37,12 +37,14 @@ die() { echo "entrypoint: FATAL: $* - refusing to start" >&2 exit 1 } -# Unlink as root: rm on the final component never touches the link target and -# the customer cannot deny it. Still a symlink afterwards -> refuse to start. +# Unlink 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. Still a symlink afterwards -> refuse to +# start (fail closed). 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 @@ -55,9 +57,11 @@ chown -h $user:$user /home/$user # alone, and every root-side operation on it below runs as the customer. drop_symlink /home/$user/logs if [ -d /home/$user/logs ]; then - # nginx opens its logs here as root. find without -L never follows symlinks; - # -delete unlinks the link itself. Re-checked fail-closed before nginx starts. - find /home/$user/logs -xdev -type l \ + # nginx opens its logs here as root. 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. Re-checked + # fail-closed immediately before nginx starts. + as_user find /home/$user/logs -xdev -type l \ -printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true fi