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.shtouched 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).
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
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.
/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:
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:
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.
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)
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>
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>
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 deny2026-10-20 23:59:13 +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 trunk2026-10-21 00:01:30 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
trunkrebuilds 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 755on the customer docroot (original commit1476159)entrypoint.shandentrypoint-fpm.shranchmod -R 755 /home/$useron every start. On whp01, 1,890 of 1,890 files on one cac-fpm site were 755, wp-config.php included. That is replaced bychmod 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 with95b9397(2023), was copied intoentrypoint-fpm.shbyc781678, 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.shtouchedlogs/php-fpm/error.logas root after itschown -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.
scripts/stop-services.shprovidesstop_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-118andentrypoint-shared-httpd.sh:68-99trap TERM/INT. They stop httpd with WINCH (graceful-stop), php-fpm with QUIT and crond with TERM, thenexit 0. The time budgets add up to less than 10s.waited. shared-httpd's 60s rescansleepis backgrounded too, so the trap runs immediately. Log streaming todocker logsis unchanged.Dockerfile.shared-httpdnow copiesstop-services.sh.4. Root never follows customer symlinks under
/home/$user(security)/home/$useris the customer's bind mount, so they can plant symlinks in it. The entrypoints run as root, and measured with the current images: a danglingcrontab -> /etc/cron.d/pwnmade root create/etc/cron.d/pwnand chown it to the customer. That gives the customer a root cron file, i.e. container root. Plantedlogs/php-fpm -> /etcandlogs/apache -> /etcalso made root daemons create/etc/error_log,/etc/access_logand/etc/slowlog.New
scripts/safe-paths.sh, used byentrypoint.shandentrypoint-fpm.sh:secure_home(entrypoint*.sh:15/19):/home/$useris not a real directory.logs/must be a real dir. Every symlink underlogs/is unlinked as the customer and logged.public_htmlmay stay a symlink (release-dir deploys); every root-side operation on it runs as the customer.setpriv, falling back torunuser).crontab -u $user -. Root reading it could otherwise install any root-readable file where the customer cancrontab -lit.crontab/mysql_credsare written withas_user tee.require_safe_logs, run immediately before httpd / php-fpm start:entrypoint.sh:42,entrypoint-fpm.sh:40):logs,logs/apacheandlogs/php-fpmmust be real dirs.logs/may be anything but a regular file or a directory.drop_symlinkanduser_touchdie if a symlink survives or the path isn't a regular file.chown -Ris unchanged: GNU's default-Plchown()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/.envand/%2eenvwith 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.configs/whp-security.confgoes into/etc/httpd/conf.d(viaDockerfile). It uses the same namedFilesMatch+DirectoryMatchlist as WHP'sshared-httpd-security.conf, re-rooted to/home.context exp:regex as WHP's shared-ols vhconf template, added toconfigs/litespeed/site-template.tpl..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:
FROMthe current registry tag plusCOPYof only the changed files. A from-scratch build was impractical because the package mirror ran at about 100 kB/s.entrypoint*.sh,create-php-config.sh,create-vhost.sh,detect-memory.sh, the litespeed templates andcreate-vhost-litespeed.sh) was checked by md5 to matchtrunkexactly. The overlay therefore differs from a CI build only in the files this PR changes.B:512: drop NET_RAW SETFCAP MKNOD SETPCAP FSETID SYS_CHROOT, no-new-privileges, pids limit) on a fresh empty site.All of these passed both before and after:
docker exec -u samtest2workshttpd -k graceful+ php-fpm USR2)docker restartDocker 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 stopsent 1s later.slow-done), the stop took 2.5s and exited 0, for both cac and shared-httpd → cac-fpm.Symlink cases. Planted before start;
/etcwas inspected inside the container afterwards.logs/php-fpm/error.log -> /etc/shadow,logs/apache/error_log -> /etc/passwd,crontab -> /etc/shadow,public_html/linkdir -> /etcshadow_in_user_crontab=0);linkdirleft alone and/etcnot chmoddedlogs/php-fpm -> /etc,logs/apache -> /etc,crontab -> /etc/cron.d/pwn/etc/cron.d/pwncreated, owned samtest2;/etc/error.log,/etc/error_log,/etc/access_log,/etc/slowlogcreated by rootlogs -> /etc,crontab -> /etc/cron.d/pwn/etc/cron.d/pwnowned samtest2;/etc/php-fpm,/etc/apachecreatedlogs/php-fpm/error.log(cac: alsologs/apache/error_log)FATAL: ... error.log is not a regular file(cac-fpm),FATAL: unsafe entry under /home/samtest2/logs(cac); no daemon startedchattr +ilog dir (cleanup can't proceed)FATAL: could not remove symlink ...(cac-fpm),FATAL: unsafe entry ...(cac);/etc/shadowstill0/0 ----------The same results held for both cac and cac-fpm in every case.
/etc/shadowstayed root:root 000 and/etc/passwdstayed root:root 644 everywhere.Dotfiles, fresh site, HTTPS:
/.env(0640)/.env.local/.git/config/.git//.htpasswd/.user.ini.bak/sub/.env/%2eenv/.well-known/acme-challenge/test/index.htmlphp-ok, uid 1003 (rechecked on the after image)No 403 response contained any file content.
Not tested:
environment=DEV) branches.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:logs/apache/*.slowlogon each slow request.log-rotate.sh(root cron) copies and truncateslogs/apache/*_logthrough[ -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:/home/$user(for example/var/log/whp/<user>/), exposed to the customer read-only (a read-only bind mount or a panel view). Or: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 (poolerror_log), opened by the workers rather than the root master.Other findings, not changed here:
log-rotate.shhas the same root-follows-symlink pattern (cpandcat /dev/null >on[ -f ]-checked paths).entrypoint-fpm.sh'sln -sf /home/$user/logs/php-fpm /var/log/php-fpmcreates/var/log/php-fpm/php-fpm, because/var/log/php-fpmalready exists in the image, so the fpm master's own error log stays inside the container.Rollout notes
logs/will now refuse to start (exit 1 with a FATAL line indocker logs). On .148, 0 of 16 sites have one. Runfind /docker/users/*/*/logs -xdev ! -type f ! -type don each host before rollout.🤖 Generated with Claude Code
fix(entrypoint): stop chmod -R 755 on the customer docroot every startto fix(entrypoint): no recursive chmod, graceful stop, fpm error_log owner, symlink-safe root paths, dotfile deny