fix(entrypoint): no recursive chmod, graceful stop, fpm error_log owner, symlink-safe root paths, dotfile deny #23

Merged
jknapp merged 7 commits from fix/no-recursive-chmod-on-start into trunk 2026-10-21 00:01:30 +00:00
Owner

Four fixes to the cac / cac-fpm / shared-httpd / cac-litespeed entrypoints and configs. Each was measured on the test server (.148) before and after. Do not merge without a deploy plan: a merge to trunk rebuilds and pushes every floating tag (cac:php74..85, cac-fpm:*, cac-litespeed:*, cac-lsphp:*, shared-httpd:latest, shared-ols:latest).

1. No more chmod -R 755 on the customer docroot (original commit 1476159)

entrypoint.sh and entrypoint-fpm.sh ran chmod -R 755 /home/$user on every start. On whp01, 1,890 of 1,890 files on one cac-fpm site were 755, wp-config.php included. That is replaced by chmod 755 /home/$user, plus adding o+x to directories that lack it, so apache can traverse them. Regular files are never touched. History: the line came in with 95b9397 (2023), was copied into entrypoint-fpm.sh by c781678, and no reason was ever given.

The other entrypoints don't have this pattern: lsphp has no chmod, litespeed only does a non-recursive chmod 755 /home/$user, and shared-httpd / shared-ols don't touch /home.

2. cac-fpm: PHP error_log is customer-owned from the first start

entrypoint-fpm.sh touched logs/php-fpm/error.log as root after its chown -R. On a site's first start the pool (customer uid) couldn't open it, so PHP errors went out as FastCGI stderr into shared-httpd's error log until the next restart. The file is now created as the customer before php-fpm starts (entrypoint-fpm.sh:28, user_touch). The other entrypoints already did this in the right order (lsphp and litespeed touch then chown; cac never touched it).

3. Graceful docker stop (cac, cac-fpm, shared-httpd, form-sender)

These entrypoints ended with bash as PID 1 tailing logs. PID 1 only receives signals it has a handler for, so docker's SIGTERM was dropped. Every stop or restart waited the full 10s and then SIGKILLed apache / php-fpm (exit 137); on shared-httpd that hits every site on the host at once.

  • New scripts/stop-services.sh provides stop_service: it sends a graceful signal, waits a bounded time, then falls back to TERM and finally KILL.
  • entrypoint.sh:110-127, entrypoint-fpm.sh:102-118 and entrypoint-shared-httpd.sh:68-99 trap TERM/INT. They stop httpd with WINCH (graceful-stop), php-fpm with QUIT and crond with TERM, then exit 0. The time budgets add up to less than 10s.
  • The log tail is backgrounded and waited. shared-httpd's 60s rescan sleep is backgrounded too, so the trap runs immediately. Log streaming to docker logs is unchanged.
  • Dockerfile.shared-httpd now copies stop-services.sh.

4. Root never follows customer symlinks under /home/$user (security)

/home/$user is the customer's bind mount, so they can plant symlinks in it. The entrypoints run as root, and measured with the current images: a dangling crontab -> /etc/cron.d/pwn made root create /etc/cron.d/pwn and chown it to the customer. That gives the customer a root cron file, i.e. container root. Planted logs/php-fpm -> /etc and logs/apache -> /etc also made root daemons create /etc/error_log, /etc/access_log and /etc/slowlog.

New scripts/safe-paths.sh, used by entrypoint.sh and entrypoint-fpm.sh:

  • secure_home (entrypoint*.sh:15/19):
    • Refuses to start if /home/$user is not a real directory.
    • logs/ must be a real dir. Every symlink under logs/ is unlinked as the customer and logged.
    • public_html may stay a symlink (release-dir deploys); every root-side operation on it runs as the customer.
  • Directories, the pool error_log and the default crontab are created as the customer (setpriv, falling back to runuser).
  • The crontab is read as the customer and piped to crontab -u $user -. Root reading it could otherwise install any root-readable file where the customer can crontab -l it.
  • DEV-mode crontab / mysql_creds are written with as_user tee.
  • The o+x directory pass runs as the customer.
  • Fail closed (require_safe_logs, run immediately before httpd / php-fpm start: entrypoint.sh:42, entrypoint-fpm.sh:40):
    • logs, logs/apache and logs/php-fpm must be real dirs.
    • Nothing under logs/ may be anything but a regular file or a directory.
    • drop_symlink and user_touch die if a symlink survives or the path isn't a regular file.
    • In each case the container exits 1 before any daemon starts. Only the customer who planted the path is affected.
  • chown -R is unchanged: GNU's default -P lchown()s symlinks and never follows them.

5. Deny config / VCS dotfiles over HTTP on standalone cac and cac-litespeed

Measured: standalone cac served /.env, /.git/config, /sub/.env and /%2eenv with their contents, and cac-litespeed did the same even at mode 0640 (OLS runs as the customer uid there). shared-httpd and shared-ols already deny these through WHP's drop-ins.

  • cac: configs/whp-security.conf goes into /etc/httpd/conf.d (via Dockerfile). It uses the same named FilesMatch + DirectoryMatch list as WHP's shared-httpd-security.conf, re-rooted to /home.
  • cac-litespeed: the same context exp: regex as WHP's shared-ols vhconf template, added to configs/litespeed/site-template.tpl.
  • The list is named (.user.ini .env* .git .svn .hg .bzr .ht* .whp .site-builder) rather than a blanket dotfile deny, so /.well-known/ stays reachable.

Test results (.148, measured)

Images:

  • cac / cac-fpm / shared-httpd / form-sender (cac:php85) / cac-litespeed were built as overlays: FROM the current registry tag plus COPY of only the changed files. A from-scratch build was impractical because the package mirror ran at about 100 kB/s.
  • Before building, every relevant script in those images (entrypoint*.sh, create-php-config.sh, create-vhost.sh, detect-memory.sh, the litespeed templates and create-vhost-litespeed.sh) was checked by md5 to match trunk exactly. The overlay therefore differs from a CI build only in the files this PR changes.
  • Every container ran with the hardening baseline (B:512: drop NET_RAW SETFCAP MKNOD SETPCAP FSETID SYS_CHROOT, no-new-privileges, pids limit) on a fresh empty site.
  • The control is the current registry image run through the same steps.
image phase docker stop docker restart seeded modes 600/640/750 file, 750/700 dir, 644 PHP error_log on cold start
cac before 10.1s, exit 137 10.3s all 755 file, uid 1003
cac after 1.4s, exit 0 1.6s 600 640 750 / 751 701 / 644 (kept after reload and restart) file, uid 1003
cac-fpm before 10.2s, exit 137 20.5s (pair) all 755 shared-httpd log (file owner 0) on cold and reload; file only after restart
cac-fpm after 0.4s, exit 0 2.0s (pair) kept file, uid 1003, marker not in shared-httpd log on cold, reload and restart
shared-httpd before 10.1s, exit 137 (pair) n/a n/a
shared-httpd after 1.3s, exit 0 (pair) n/a n/a
form-sender (cac:php85) before 10.1s, exit 137 10.3s n/a n/a
form-sender (cac:php85) after 1.4s, exit 0 1.6s n/a n/a

All of these passed both before and after:

  • cold start and health
  • HTTPS PHP runs as uid 1003 and can write
  • a static file inside the 0750 directory is served (200), directly by cac and through shared-httpd for cac-fpm
  • php-fpm pool runs as 1003, apache workers as uid 48
  • crond runs and the user cron fires as 1003
  • docker exec -u samtest2 works
  • graceful reload (httpd -k graceful + php-fpm USR2)
  • docker restart

Docker logs show the graceful path was taken every time (stopping httpd with SIGWINCH, httpd stopped, php-fpm stopped, crond stopped), with no TERM or KILL fallback.

In-flight request test: a 3s PHP request was started and docker stop sent 1s later.

  • Before: the request returned 200, but the stop took 10.1s and exited 137.
  • After: the request still returned 200 (slow-done), the stop took 2.5s and exited 0, for both cac and shared-httpd → cac-fpm.

Symlink cases. Planted before start; /etc was inspected inside the container afterwards.

case before after
A: logs/php-fpm/error.log -> /etc/shadow, logs/apache/error_log -> /etc/passwd, crontab -> /etc/shadow, public_html/linkdir -> /etc /etc unchanged; links left in place /etc unchanged; log links removed and logged; crontab not loaded (shadow_in_user_crontab=0); linkdir left alone and /etc not chmodded
B: logs/php-fpm -> /etc, logs/apache -> /etc, crontab -> /etc/cron.d/pwn /etc/cron.d/pwn created, owned samtest2; /etc/error.log, /etc/error_log, /etc/access_log, /etc/slowlog created by root nothing created in /etc; links removed; starts healthy
C: logs -> /etc, crontab -> /etc/cron.d/pwn /etc/cron.d/pwn owned samtest2; /etc/php-fpm, /etc/apache created nothing created; starts healthy
F1: FIFO planted as logs/php-fpm/error.log (cac: also logs/apache/error_log) cac-fpm started healthy; cac started but unhealthy exit 1: FATAL: ... error.log is not a regular file (cac-fpm), FATAL: unsafe entry under /home/samtest2/logs (cac); no daemon started
F2: symlinks inside a chattr +i log dir (cleanup can't proceed) started, unhealthy, links left exit 1: FATAL: could not remove symlink ... (cac-fpm), FATAL: unsafe entry ... (cac); /etc/shadow still 0/0 ----------

The same results held for both cac and cac-fpm in every case. /etc/shadow stayed root:root 000 and /etc/passwd stayed root:root 644 everywhere.

Dotfiles, fresh site, HTTPS:

URL cac before cac after cac-litespeed before cac-litespeed after
/.env (0640) 200 + contents 403 200 + contents 403
/.env.local 200 + contents 403 200 + contents 403
/.git/config 200 + contents 403 200 + contents 403
/.git/ 200 403 404 403
/.htpasswd 403 403 403 403
/.user.ini.bak 200 + contents 403 200 + contents 403
/sub/.env 200 + contents 403 200 + contents 403
/%2eenv 200 + contents 403 200 + contents 403
/.well-known/acme-challenge/test 200 + body 200 + body 200 + body 200 + body
/index.html 200 200 200 200
PHP php-ok, uid 1003 (rechecked on the after image) 200, uid 1003 200, uid 1003

No 403 response contained any file content.

Not tested:

  • cac-lsphp, shared-ols and cac-litespeed lifecycle: their entrypoints are unchanged. For litespeed only the vhost template changed, and that was tested above.
  • DEV-mode (environment=DEV) branches.
  • PHP versions other than 83 (and 85 via form-sender).
  • A real CI build: the overlay method is described above.

Residual risk (pre-existing; not redesigned here)

httpd and php-fpm start as root and open log files under /home/$user/logs/, which the customer can write to:

  • httpd opens logs/apache/*.
  • the php-fpm master opens the slowlog on each slow request.
  • log-rotate.sh (root cron) copies and truncates logs/apache/*_log through [ -f ] checks.

This PR removes planted symlinks and fails closed immediately before the daemons start. But a customer who re-plants a link after that check, for example before a graceful reload, a log reopen, the next slow request or the next log-rotate run, can still point a root open() or truncate at another path. Closing that race needs a layout change, which is Josh's call because it affects customer FTP / file-manager access to logs and rotation:

  1. Master and daemon logs go in a root-owned directory outside /home/$user (for example /var/log/whp/<user>/), exposed to the customer read-only (a read-only bind mount or a panel view). Or:
  2. Make logs/ and its subdirectories root-owned and not writable by the customer (0755), with pre-created customer-owned files for the logs that customer-uid processes write (pool error_log), opened by the workers rather than the root master.

Other findings, not changed here: log-rotate.sh has the same root-follows-symlink pattern (cp and cat /dev/null > on [ -f ]-checked paths). entrypoint-fpm.sh's ln -sf /home/$user/logs/php-fpm /var/log/php-fpm creates /var/log/php-fpm/php-fpm, because /var/log/php-fpm already exists in the image, so the fpm master's own error log stays inside the container.

Rollout notes

  • Files that are already 755 stay 755; the cleanup is a separate job.
  • Static files uploaded without o+r now get a 403 instead of being "fixed" on restart.
  • A site with a symlink, FIFO or socket under logs/ will now refuse to start (exit 1 with a FATAL line in docker logs). On .148, 0 of 16 sites have one. Run find /docker/users/*/*/logs -xdev ! -type f ! -type d on each host before rollout.

🤖 Generated with Claude Code

Four fixes to the cac / cac-fpm / shared-httpd / cac-litespeed entrypoints and configs. Each was measured on the test server (.148) before and after. **Do not merge without a deploy plan:** a merge to `trunk` rebuilds and pushes every floating tag (`cac:php74..85`, `cac-fpm:*`, `cac-litespeed:*`, `cac-lsphp:*`, `shared-httpd:latest`, `shared-ols:latest`). ## 1. No more `chmod -R 755` on the customer docroot (original commit `1476159`) `entrypoint.sh` and `entrypoint-fpm.sh` ran `chmod -R 755 /home/$user` on every start. On whp01, 1,890 of 1,890 files on one cac-fpm site were 755, wp-config.php included. That is replaced by `chmod 755 /home/$user`, plus adding o+x to directories that lack it, so apache can traverse them. Regular files are never touched. History: the line came in with `95b9397` (2023), was copied into `entrypoint-fpm.sh` by `c781678`, and no reason was ever given. The other entrypoints don't have this pattern: lsphp has no chmod, litespeed only does a non-recursive `chmod 755 /home/$user`, and shared-httpd / shared-ols don't touch `/home`. ## 2. cac-fpm: PHP error_log is customer-owned from the first start `entrypoint-fpm.sh` `touch`ed `logs/php-fpm/error.log` as root *after* its `chown -R`. On a site's first start the pool (customer uid) couldn't open it, so PHP errors went out as FastCGI stderr into shared-httpd's error log until the next restart. The file is now created as the customer before php-fpm starts (`entrypoint-fpm.sh:28`, `user_touch`). The other entrypoints already did this in the right order (lsphp and litespeed touch then chown; cac never touched it). ## 3. Graceful `docker stop` (cac, cac-fpm, shared-httpd, form-sender) These entrypoints ended with bash as PID 1 tailing logs. PID 1 only receives signals it has a handler for, so docker's SIGTERM was dropped. Every stop or restart waited the full 10s and then SIGKILLed apache / php-fpm (exit 137); on shared-httpd that hits every site on the host at once. - New `scripts/stop-services.sh` provides `stop_service`: it sends a graceful signal, waits a bounded time, then falls back to TERM and finally KILL. - `entrypoint.sh:110-127`, `entrypoint-fpm.sh:102-118` and `entrypoint-shared-httpd.sh:68-99` trap TERM/INT. They stop httpd with WINCH (graceful-stop), php-fpm with QUIT and crond with TERM, then `exit 0`. The time budgets add up to less than 10s. - The log tail is backgrounded and `wait`ed. shared-httpd's 60s rescan `sleep` is backgrounded too, so the trap runs immediately. Log streaming to `docker logs` is unchanged. - `Dockerfile.shared-httpd` now copies `stop-services.sh`. ## 4. Root never follows customer symlinks under `/home/$user` (security) `/home/$user` is the customer's bind mount, so they can plant symlinks in it. The entrypoints run as root, and **measured with the current images**: a dangling `crontab -> /etc/cron.d/pwn` made root create `/etc/cron.d/pwn` and chown it to the customer. That gives the customer a root cron file, i.e. container root. Planted `logs/php-fpm -> /etc` and `logs/apache -> /etc` also made root daemons create `/etc/error_log`, `/etc/access_log` and `/etc/slowlog`. New `scripts/safe-paths.sh`, used by `entrypoint.sh` and `entrypoint-fpm.sh`: - `secure_home` (`entrypoint*.sh:15/19`): - Refuses to start if `/home/$user` is not a real directory. - `logs/` must be a real dir. Every symlink under `logs/` is unlinked **as the customer** and logged. - `public_html` may stay a symlink (release-dir deploys); every root-side operation on it runs as the customer. - Directories, the pool error_log and the default crontab are **created as the customer** (`setpriv`, falling back to `runuser`). - The crontab is **read as the customer** and piped to `crontab -u $user -`. Root reading it could otherwise install any root-readable file where the customer can `crontab -l` it. - DEV-mode `crontab` / `mysql_creds` are written with `as_user tee`. - The o+x directory pass runs as the customer. - **Fail closed** (`require_safe_logs`, run immediately before httpd / php-fpm start: `entrypoint.sh:42`, `entrypoint-fpm.sh:40`): - `logs`, `logs/apache` and `logs/php-fpm` must be real dirs. - Nothing under `logs/` may be anything but a regular file or a directory. - `drop_symlink` and `user_touch` die if a symlink survives or the path isn't a regular file. - In each case the container exits 1 before any daemon starts. Only the customer who planted the path is affected. - `chown -R` is unchanged: GNU's default `-P` lchown()s symlinks and never follows them. ## 5. Deny config / VCS dotfiles over HTTP on standalone cac and cac-litespeed Measured: standalone cac served `/.env`, `/.git/config`, `/sub/.env` and `/%2eenv` with their contents, and cac-litespeed did the same even at mode 0640 (OLS runs as the customer uid there). shared-httpd and shared-ols already deny these through WHP's drop-ins. - cac: `configs/whp-security.conf` goes into `/etc/httpd/conf.d` (via `Dockerfile`). It uses the same named `FilesMatch` + `DirectoryMatch` list as WHP's `shared-httpd-security.conf`, re-rooted to `/home`. - cac-litespeed: the same `context exp:` regex as WHP's shared-ols vhconf template, added to `configs/litespeed/site-template.tpl`. - The list is named (`.user.ini .env* .git .svn .hg .bzr .ht* .whp .site-builder`) rather than a blanket dotfile deny, so `/.well-known/` stays reachable. ## Test results (.148, measured) Images: - cac / cac-fpm / shared-httpd / form-sender (cac:php85) / cac-litespeed were built as **overlays**: `FROM` the current registry tag plus `COPY` of only the changed files. A from-scratch build was impractical because the package mirror ran at about 100 kB/s. - Before building, every relevant script in those images (`entrypoint*.sh`, `create-php-config.sh`, `create-vhost.sh`, `detect-memory.sh`, the litespeed templates and `create-vhost-litespeed.sh`) was checked by md5 to match `trunk` exactly. The overlay therefore differs from a CI build only in the files this PR changes. - Every container ran with the hardening baseline (`B:512`: drop NET_RAW SETFCAP MKNOD SETPCAP FSETID SYS_CHROOT, no-new-privileges, pids limit) on a fresh empty site. - The control is the current registry image run through the same steps. | image | phase | docker stop | docker restart | seeded modes 600/640/750 file, 750/700 dir, 644 | PHP error_log on cold start | |---|---|---|---|---|---| | cac | before | 10.1s, exit 137 | 10.3s | all 755 | file, uid 1003 | | cac | after | **1.4s, exit 0** | 1.6s | **600 640 750 / 751 701 / 644** (kept after reload and restart) | file, uid 1003 | | cac-fpm | before | 10.2s, exit 137 | 20.5s (pair) | all 755 | **shared-httpd log** (file owner 0) on cold and reload; file only after restart | | cac-fpm | after | **0.4s, exit 0** | 2.0s (pair) | **kept** | **file, uid 1003, marker not in shared-httpd log** on cold, reload and restart | | shared-httpd | before | 10.1s, exit 137 | (pair) | n/a | n/a | | shared-httpd | after | **1.3s, exit 0** | (pair) | n/a | n/a | | form-sender (cac:php85) | before | 10.1s, exit 137 | 10.3s | n/a | n/a | | form-sender (cac:php85) | after | **1.4s, exit 0** | 1.6s | n/a | n/a | All of these passed both before and after: - cold start and health - HTTPS PHP runs as uid 1003 and can write - a static file inside the 0750 directory is served (200), directly by cac and through shared-httpd for cac-fpm - php-fpm pool runs as 1003, apache workers as uid 48 - crond runs and the user cron fires as 1003 - `docker exec -u samtest2` works - graceful reload (`httpd -k graceful` + php-fpm USR2) - `docker restart` Docker logs show the graceful path was taken every time (`stopping httpd with SIGWINCH`, `httpd stopped`, `php-fpm stopped`, `crond stopped`), with no TERM or KILL fallback. In-flight request test: a 3s PHP request was started and `docker stop` sent 1s later. - Before: the request returned 200, but the stop took 10.1s and exited 137. - After: the request still returned 200 (`slow-done`), the stop took 2.5s and exited 0, for both cac and shared-httpd → cac-fpm. Symlink cases. Planted before start; `/etc` was inspected inside the container afterwards. | case | before | after | |---|---|---| | A: `logs/php-fpm/error.log -> /etc/shadow`, `logs/apache/error_log -> /etc/passwd`, `crontab -> /etc/shadow`, `public_html/linkdir -> /etc` | /etc unchanged; links left in place | /etc unchanged; log links removed and logged; crontab not loaded (`shadow_in_user_crontab=0`); `linkdir` left alone and `/etc` not chmodded | | B: `logs/php-fpm -> /etc`, `logs/apache -> /etc`, `crontab -> /etc/cron.d/pwn` | **`/etc/cron.d/pwn` created, owned samtest2**; `/etc/error.log`, `/etc/error_log`, `/etc/access_log`, `/etc/slowlog` created by root | nothing created in /etc; links removed; starts healthy | | C: `logs -> /etc`, `crontab -> /etc/cron.d/pwn` | **`/etc/cron.d/pwn` owned samtest2**; `/etc/php-fpm`, `/etc/apache` created | nothing created; starts healthy | | F1: FIFO planted as `logs/php-fpm/error.log` (cac: also `logs/apache/error_log`) | cac-fpm started healthy; cac started but unhealthy | **exit 1**: `FATAL: ... error.log is not a regular file` (cac-fpm), `FATAL: unsafe entry under /home/samtest2/logs` (cac); no daemon started | | F2: symlinks inside a `chattr +i` log dir (cleanup can't proceed) | started, unhealthy, links left | **exit 1**: `FATAL: could not remove symlink ...` (cac-fpm), `FATAL: unsafe entry ...` (cac); `/etc/shadow` still `0/0 ----------` | The same results held for both cac and cac-fpm in every case. `/etc/shadow` stayed root:root 000 and `/etc/passwd` stayed root:root 644 everywhere. Dotfiles, fresh site, HTTPS: | URL | cac before | cac after | cac-litespeed before | cac-litespeed after | |---|---|---|---|---| | `/.env` (0640) | 200 + contents | 403 | 200 + contents | 403 | | `/.env.local` | 200 + contents | 403 | 200 + contents | 403 | | `/.git/config` | 200 + contents | 403 | 200 + contents | 403 | | `/.git/` | 200 | 403 | 404 | 403 | | `/.htpasswd` | 403 | 403 | 403 | 403 | | `/.user.ini.bak` | 200 + contents | 403 | 200 + contents | 403 | | `/sub/.env` | 200 + contents | 403 | 200 + contents | 403 | | `/%2eenv` | 200 + contents | 403 | 200 + contents | 403 | | `/.well-known/acme-challenge/test` | 200 + body | **200 + body** | 200 + body | **200 + body** | | `/index.html` | 200 | 200 | 200 | 200 | | PHP | `php-ok`, uid 1003 (rechecked on the after image) | | 200, uid 1003 | 200, uid 1003 | No 403 response contained any file content. Not tested: - cac-lsphp, shared-ols and cac-litespeed lifecycle: their entrypoints are unchanged. For litespeed only the vhost template changed, and that was tested above. - DEV-mode (`environment=DEV`) branches. - PHP versions other than 83 (and 85 via form-sender). - A real CI build: the overlay method is described above. ## Residual risk (pre-existing; not redesigned here) httpd and php-fpm start as root and open log files under `/home/$user/logs/`, which the customer can write to: - httpd opens `logs/apache/*`. - the php-fpm master opens the `slowlog` on each slow request. - `log-rotate.sh` (root cron) copies and truncates `logs/apache/*_log` through `[ -f ]` checks. This PR removes planted symlinks and fails closed immediately before the daemons start. But a customer who re-plants a link **after** that check, for example before a graceful reload, a log reopen, the next slow request or the next log-rotate run, can still point a root `open()` or truncate at another path. Closing that race needs a layout change, which is Josh's call because it affects customer FTP / file-manager access to logs and rotation: 1. Master and daemon logs go in a root-owned directory outside `/home/$user` (for example `/var/log/whp/<user>/`), exposed to the customer read-only (a read-only bind mount or a panel view). Or: 2. Make `logs/` and its subdirectories root-owned and not writable by the customer (0755), with pre-created customer-owned files for the logs that customer-uid processes write (pool `error_log`), opened by the workers rather than the root master. Other findings, not changed here: `log-rotate.sh` has the same root-follows-symlink pattern (`cp` and `cat /dev/null >` on `[ -f ]`-checked paths). `entrypoint-fpm.sh`'s `ln -sf /home/$user/logs/php-fpm /var/log/php-fpm` creates `/var/log/php-fpm/php-fpm`, because `/var/log/php-fpm` already exists in the image, so the fpm master's own error log stays inside the container. ## Rollout notes - Files that are already 755 stay 755; the cleanup is a separate job. - Static files uploaded without o+r now get a 403 instead of being "fixed" on restart. - A site with a symlink, FIFO or socket under `logs/` will now **refuse to start** (exit 1 with a FATAL line in `docker logs`). On .148, 0 of 16 sites have one. Run `find /docker/users/*/*/logs -xdev ! -type f ! -type d` on each host before rollout. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 1 commit 2026-10-20 19:33:46 +00:00
entrypoint.sh and entrypoint-fpm.sh ran `chmod -R 755 /home/$user` on every
container start. /home/$user is the bind-mounted customer site directory, so
every file (wp-config.php included) became world-readable and executable, and
any tighter permissions the customer set were reverted on each restart.

The line dates from the initial commit (95b9397) and was copied into the FPM
entrypoint (c781678); neither gives a reason. The legitimate need is that
Apache (bundled httpd, or shared-httpd reading /mnt/users/...) runs as
`apache`, not the customer, and must be able to traverse directories to serve
static files.

Keep the recursive chown. Replace the recursive chmod with:
- chmod 755 on the docroot directory itself (matches entrypoint-litespeed.sh)
- o+x on directories that lack it (find -type d ! -perm -o=x)
Regular files are never touched: execute is never added, and wp-config.php or
any other customer-restricted file is never loosened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp added 4 commits 2026-10-20 23:05:53 +00:00
Graceful stop (cac, cac-fpm, shared-httpd, and form-sender which runs cac):
entrypoint.sh, entrypoint-fpm.sh and entrypoint-shared-httpd.sh end with bash
as PID 1 tailing logs. PID 1 only receives signals it has a handler for, so
docker stop's SIGTERM was dropped; docker waited the full 10s and SIGKILLed
apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT and
stop each daemon they started via the new scripts/stop-services.sh: httpd
graceful-stop (WINCH), php-fpm QUIT, crond TERM, each with a bounded wait and a
TERM/KILL fallback, budgets summing to < 10s, then exit 0. The log tail is
backgrounded + `wait`ed (and shared-httpd's 60s rescan sleep likewise) so the
trap runs immediately. Log streaming to docker logs is unchanged.

cac-fpm error_log: entrypoint-fpm.sh `touch`ed logs/php-fpm/error.log as root
AFTER its chown -R, so on a site's first start the pool (customer uid) could
not open it and PHP errors went out as FastCGI stderr to shared-httpd's log
until the next restart. The file is now created and chowned to the customer
right after the logs dir is made, before php-fpm starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/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>
drop_symlink and the logs/ symlink sweep now unlink AS THE CUSTOMER, and
user_touch no longer falls back to a root chown -h (a parent dir swapped for a
symlink between check and use could still redirect a root path operation).
A root-owned leftover error_log is fixed by the existing chown -R, which never
follows symlinks. Every operation these helpers perform is now safe even if the
customer races it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
drop_symlink unlinks as root (rm on the final component never follows the
target and the customer cannot deny it) and refuses to start if the path is
still a symlink. user_touch refuses unless it ends up a regular file. New
require_safe_logs runs immediately before httpd / php-fpm start (they open
files under /home/$user/logs as root): logs/, logs/apache, logs/php-fpm must be
real directories and nothing under logs/ may be other than a regular file or
directory, else the entrypoint exits 1 without starting any daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp added 1 commit 2026-10-20 23:06:42 +00:00
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 <noreply@anthropic.com>
jknapp changed title from fix(entrypoint): stop chmod -R 755 on the customer docroot every start to fix(entrypoint): no recursive chmod, graceful stop, fpm error_log owner, symlink-safe root paths, dotfile deny 2026-10-20 23:59:13 +00:00
jknapp added 1 commit 2026-10-20 23:59:24 +00:00
Measured on .148: standalone cac served /.env with its contents, and
cac-litespeed served /.env even at mode 0640 (OLS runs as the customer uid
there). shared-httpd and shared-ols already deny these via WHP's drop-ins; the
standalone tiers never got them.

- cac: new configs/whp-security.conf in /etc/httpd/conf.d — the same named
  FilesMatch + DirectoryMatch list as WHP's shared-httpd-security.conf,
  re-rooted to /home.
- cac-litespeed: the same `context exp:` regex as WHP's shared-ols vhconf
  template, added to the vhost template.

Named list (.user.ini .env* .git .svn .hg .bzr .ht* .whp .site-builder), not a
blanket dotfile deny, so /.well-known/ stays reachable. HTTP only: PHP still
reads these files from disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp merged commit 74e12407f8 into trunk 2026-10-21 00:01:30 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cloud-hosting-platform/cloud-apache-container#23