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.
/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 +ilogs/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:
nginx logs go in a root-owned directory outside /home/$user, exposed read-only. Or:
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.
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)
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>
/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>
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 symlinks2026-10-20 23:59:13 +00:00
jknapp
merged commit a1364d7d52 into main2026-10-21 00:01:32 +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.
Two fixes to
scripts/entrypoint.sh, measured on the test server (.148). Do not merge without a deploy plan: a push tomainrebuilds and pushescnoc:node18/20/22andcnoc:latest.1. No more
chmod -R 755on the customer home (original commit3ad03be)The entrypoint ran
chown -Rfollowed bychmod -R 755 /home/$useron every start, so every file (.envand other app secrets) became world-readable and executable. That is replaced bychmod 755 /home/$userplus adding o+x to directories that lack it. nginx serves/static/fromapp/public/asnginxand 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 tomain):app/ecosystem.config.js -> /etc/cron.d/pwnmade root write/etc/cron.d/pwn(ingenerate-ecosystem-config.sh) and thenchownit to the customer. That gives the customer a root cron file, i.e. container root.logs/nginx -> /etcmade root nginx create/etc/error.logand/etc/access.log.Changes:
as_user(setpriv, falling back torunuser),dieanddrop_symlinkhelpers (lines 27-52)./home/$useris not a real directory.logs/must be a real directory. Symlinks under it are unlinked as the customer and logged;drop_symlinkdies if a link survives.app/, thelogs/*dirs, the example-app copy andecosystem.config.jsare created as the customer. The non-recursive dereferencingchowns are gone.app/itself may stay a symlink (releases/currentdeploys).logs,logs/nginxandlogs/nodejsmust be real dirs, and nothing underlogs/may be anything but a regular file or a directory. Otherwise the container exits 1.chown -Ris unchanged: GNU's default-Pnever follows symlinks.Graceful stop needed no change here:
dumb-init --single-childis 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:
cnoc:node22, pulled by digestsha256:de617ef7…into a throwaway tag. AlmaLinux 10 with dumb-init; every/scripts/*file md5-matchesmain.scripts/entrypoint.shcopied on top.B:512, fresh empty site./static/style.cssapp/publicafter start, checked after restartdocker exec -u,pm2 reload+nginx -s reloadSymlink cases (
/etcinspected inside the container after start):logs/nginx -> /etc,app/ecosystem.config.js -> /etc/cron.d/pwn/etc/cron.d/pwnowned samtest2;/etc/error.logand/etc/access.logcreated by rootlogs -> /etc, same ecosystem link/etc/cron.d/pwnowned samtest2; unhealthylogs/nginx/error.logFATAL: unsafe entry under /home/samtest2/logs; nginx never startedchattr +ilogs/nginx/etc/shadowstill0/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/*.loginside the customer-writable/home/$user. This PR removes planted links and fails closed immediately before nginx starts. A link re-planted afterwards (beforenginx -s reload, or beforelog-rotate.shsends USR1 to reopen the logs) can still redirect a rootopen(). Closing that needs a layout change, which is Josh's call:/home/$user, exposed read-only. Or:logs/nginxroot-owned and not writable by the customer, with the files pre-created.Rollout notes
logs/will now refuse to start (FATAL indocker logs). Scan hosts first withfind /docker/users/*/*/logs -xdev ! -type f ! -type d.🤖 Generated with Claude Code
fix(entrypoint): stop chmod -R 755 on the customer home every startto fix(entrypoint): no recursive chmod on the customer home; root never follows customer symlinks