fix(entrypoint): no recursive chmod on the customer home; root never follows customer symlinks #3

Merged
jknapp merged 5 commits from fix/no-recursive-chmod-on-start into main 2026-10-21 00:01:32 +00:00
Owner

Two fixes to scripts/entrypoint.sh, measured on the test server (.148). Do not merge without a deploy plan: a push to main rebuilds and pushes cnoc:node18/20/22 and cnoc:latest.

1. No more chmod -R 755 on the customer home (original commit 3ad03be)

The entrypoint ran chown -R followed by chmod -R 755 /home/$user on every start, so every file (.env and other app secrets) became world-readable and executable. That is replaced by chmod 755 /home/$user plus adding o+x to directories that lack it. nginx serves /static/ from app/public/ as nginx and needs to traverse those directories. Regular files are never touched.

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

Measured with the current cnoc:node22 (the registry image, md5-identical to main):

  • A dangling app/ecosystem.config.js -> /etc/cron.d/pwn made root write /etc/cron.d/pwn (in generate-ecosystem-config.sh) and then chown it to the customer. That gives the customer a root cron file, i.e. container root.
  • logs/nginx -> /etc made root nginx create /etc/error.log and /etc/access.log.

Changes:

  • as_user (setpriv, falling back to runuser), die and drop_symlink helpers (lines 27-52).
  • Refuse to start if /home/$user is not a real directory.
  • logs/ must be a real directory. Symlinks under it are unlinked as the customer and logged; drop_symlink dies if a link survives.
  • app/, the logs/* dirs, the example-app copy and ecosystem.config.js are created as the customer. The non-recursive dereferencing chowns are gone.
  • app/ itself may stay a symlink (releases/current deploys).
  • The o+x directory pass runs as the customer (line 93).
  • Fail closed immediately before nginx starts (lines 95-102): logs, logs/nginx and logs/nodejs must be real dirs, and nothing under logs/ may be anything but a regular file or a directory. Otherwise the container exits 1.
  • chown -R is unchanged: GNU's default -P never follows symlinks.

Graceful stop needed no change here: dumb-init --single-child is already PID 1. It was measured at 2.1–2.2s with exit 143 both before and after.

Test results (.148, measured)

Images and setup:

  • Control: the registry cnoc:node22, pulled by digest sha256:de617ef7… into a throwaway tag. AlmaLinux 10 with dumb-init; every /scripts/* file md5-matches main.
  • After: the same image with only this branch's scripts/entrypoint.sh copied on top.
  • Hardening baseline B:512, fresh empty site.
before after
seeded 0600 / 0640 / 0750 file, 0750 / 0700 dir, 0644 file all 755 600 640 750 / 751 701 / 644, kept after reload and restart
home dir (pre-set 0700) 755 755
nginx /static/style.css 200 200
nginx static file in a 0750 dir created under app/public after start, checked after restart 200 (dir became 755) 200 (dir became 751)
node runs as uid 1003, nginx workers, crond, docker exec -u, pm2 reload + nginx -s reload pass pass
docker restart / stop 2.3s / 2.1s, exit 143 2.3s / 2.2s, exit 143

Symlink cases (/etc inspected inside the container after start):

case before after
A: logs/nginx -> /etc, app/ecosystem.config.js -> /etc/cron.d/pwn /etc/cron.d/pwn owned samtest2; /etc/error.log and /etc/access.log created by root nothing created; both links removed and logged; healthy
C: logs -> /etc, same ecosystem link /etc/cron.d/pwn owned samtest2; unhealthy nothing created; links removed; healthy
F1: FIFO planted as logs/nginx/error.log started, unhealthy exit 1: FATAL: unsafe entry under /home/samtest2/logs; nginx never started
F2: symlink inside a chattr +i logs/nginx started, unhealthy exit 1, same FATAL; /etc/shadow still 0/0 ----------

Not tested: node18 / node20 images, and DEV mode.

Residual risk (pre-existing; not redesigned here)

nginx's master starts as root and opens logs/nginx/*.log inside the customer-writable /home/$user. This PR removes planted links and fails closed immediately before nginx starts. A link re-planted afterwards (before nginx -s reload, or before log-rotate.sh sends USR1 to reopen the logs) can still redirect a root open(). Closing that needs a layout change, which is Josh's call:

  1. nginx logs go in a root-owned directory outside /home/$user, exposed read-only. Or:
  2. Make logs/nginx root-owned and not writable by the customer, with the files pre-created.

Rollout notes

  • Files that are already 755 stay 755.
  • A site with a symlink, FIFO or socket under logs/ will now refuse to start (FATAL in docker logs). Scan hosts first with find /docker/users/*/*/logs -xdev ! -type f ! -type d.

🤖 Generated with Claude Code

Two fixes to `scripts/entrypoint.sh`, measured on the test server (.148). **Do not merge without a deploy plan:** a push to `main` rebuilds and pushes `cnoc:node18/20/22` and `cnoc:latest`. ## 1. No more `chmod -R 755` on the customer home (original commit `3ad03be`) The entrypoint ran `chown -R` followed by `chmod -R 755 /home/$user` on every start, so every file (`.env` and other app secrets) became world-readable and executable. That is replaced by `chmod 755 /home/$user` plus adding o+x to directories that lack it. nginx serves `/static/` from `app/public/` as `nginx` and needs to traverse those directories. Regular files are never touched. ## 2. Root never follows customer symlinks under `/home/$user` (security) **Measured with the current `cnoc:node22`** (the registry image, md5-identical to `main`): - A dangling `app/ecosystem.config.js -> /etc/cron.d/pwn` made root write `/etc/cron.d/pwn` (in `generate-ecosystem-config.sh`) and then `chown` it to the customer. That gives the customer a root cron file, i.e. container root. - `logs/nginx -> /etc` made root nginx create `/etc/error.log` and `/etc/access.log`. Changes: - `as_user` (`setpriv`, falling back to `runuser`), `die` and `drop_symlink` helpers (lines 27-52). - Refuse to start if `/home/$user` is not a real directory. - `logs/` must be a real directory. Symlinks under it are unlinked **as the customer** and logged; `drop_symlink` dies if a link survives. - `app/`, the `logs/*` dirs, the example-app copy and `ecosystem.config.js` are created **as the customer**. The non-recursive dereferencing `chown`s are gone. - `app/` itself may stay a symlink (`releases/current` deploys). - The o+x directory pass runs as the customer (line 93). - **Fail closed** immediately before nginx starts (lines 95-102): `logs`, `logs/nginx` and `logs/nodejs` must be real dirs, and nothing under `logs/` may be anything but a regular file or a directory. Otherwise the container exits 1. - `chown -R` is unchanged: GNU's default `-P` never follows symlinks. Graceful stop needed no change here: `dumb-init --single-child` is already PID 1. It was measured at 2.1–2.2s with exit 143 both before and after. ## Test results (.148, measured) Images and setup: - Control: the registry `cnoc:node22`, pulled by digest `sha256:de617ef7…` into a throwaway tag. AlmaLinux 10 with dumb-init; every `/scripts/*` file md5-matches `main`. - After: the same image with only this branch's `scripts/entrypoint.sh` copied on top. - Hardening baseline `B:512`, fresh empty site. | | before | after | |---|---|---| | seeded 0600 / 0640 / 0750 file, 0750 / 0700 dir, 0644 file | all 755 | **600 640 750 / 751 701 / 644**, kept after reload and restart | | home dir (pre-set 0700) | 755 | 755 | | nginx `/static/style.css` | 200 | 200 | | nginx static file in a 0750 dir created under `app/public` after start, checked after restart | 200 (dir became 755) | **200** (dir became 751) | | node runs as uid 1003, nginx workers, crond, `docker exec -u`, `pm2 reload` + `nginx -s reload` | pass | pass | | docker restart / stop | 2.3s / 2.1s, exit 143 | 2.3s / 2.2s, exit 143 | Symlink cases (`/etc` inspected inside the container after start): | case | before | after | |---|---|---| | A: `logs/nginx -> /etc`, `app/ecosystem.config.js -> /etc/cron.d/pwn` | **`/etc/cron.d/pwn` owned samtest2**; `/etc/error.log` and `/etc/access.log` created by root | nothing created; both links removed and logged; healthy | | C: `logs -> /etc`, same ecosystem link | **`/etc/cron.d/pwn` owned samtest2**; unhealthy | nothing created; links removed; healthy | | F1: FIFO planted as `logs/nginx/error.log` | started, unhealthy | **exit 1**: `FATAL: unsafe entry under /home/samtest2/logs`; nginx never started | | F2: symlink inside a `chattr +i` `logs/nginx` | started, unhealthy | **exit 1**, same FATAL; `/etc/shadow` still `0/0 ----------` | Not tested: node18 / node20 images, and DEV mode. ## Residual risk (pre-existing; not redesigned here) nginx's master starts as root and opens `logs/nginx/*.log` inside the customer-writable `/home/$user`. This PR removes planted links and fails closed immediately before nginx starts. A link re-planted afterwards (before `nginx -s reload`, or before `log-rotate.sh` sends USR1 to reopen the logs) can still redirect a root `open()`. Closing that needs a layout change, which is Josh's call: 1. nginx logs go in a root-owned directory outside `/home/$user`, exposed read-only. Or: 2. Make `logs/nginx` root-owned and not writable by the customer, with the files pre-created. ## Rollout notes - Files that are already 755 stay 755. - A site with a symlink, FIFO or socket under `logs/` will now refuse to start (FATAL in `docker logs`). Scan hosts first with `find /docker/users/*/*/logs -xdev ! -type f ! -type d`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 1 commit 2026-10-20 19:34:51 +00:00
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 <noreply@anthropic.com>
jknapp added 3 commits 2026-10-20 23:05:53 +00:00
/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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <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 home every start to fix(entrypoint): no recursive chmod on the customer home; root never follows customer symlinks 2026-10-20 23:59:13 +00:00
jknapp merged commit a1364d7d52 into main 2026-10-21 00:01:32 +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-node-container#3