diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 818787534a..f97b4f7633 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -19,24 +19,30 @@ if [ "$(id -u)" -ne 0 ]; then fi # Adjust the node user's UID/GID if they differ from the runtime request -# and fix volume ownership only when a remap is needed -changed=0 - if [ "$(id -u node)" -ne "$PUID" ]; then echo "Updating node UID to $PUID" usermod -o -u "$PUID" node - changed=1 fi if [ "$(id -g node)" -ne "$PGID" ]; then echo "Updating node GID to $PGID" groupmod -o -g "$PGID" node usermod -g "$PGID" node - changed=1 fi -if [ "$changed" = "1" ]; then - chown -R node:node /paperclip +# Ensure the app home is owned by the runtime user BEFORE dropping +# privileges -- not only after a UID/GID remap. A freshly mounted volume +# (Docker named volume, Railway volume, Kubernetes PV) arrives root-owned +# and shadows the image's build-time chown, so with the default UID the old +# remap-only condition dropped privileges onto an unwritable home and the +# server crashed on its first mkdir. The probe is a first-mismatch find +# over the WHOLE tree (uid and gid): a root-owned mount or descendant +# (init containers, backup restores, files written before a remap) is +# found immediately and repaired recursively, a GID-only remap is caught, +# and a fully-correct tree costs one metadata-only walk with no chown. +home_dir="${PAPERCLIP_HOME:-/paperclip}" +if [ -d "$home_dir" ] && [ -n "$(find "$home_dir" \( ! -user node -o ! -group node \) -print -quit 2>/dev/null)" ]; then + chown -R node:node "$home_dir" fi exec gosu node "$@" diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts index 3099f01833..102eca1b8b 100644 --- a/server/src/__tests__/docker-entrypoint.test.ts +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -32,7 +32,7 @@ function writeStub(name: string, body: string) { writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); } -function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid?: number }) { +function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid?: number; homeMismatch?: boolean }) { writeStub( "id", [ @@ -46,6 +46,12 @@ function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid for (const cmd of ["usermod", "groupmod", "chown"]) { writeStub(cmd, `echo "${cmd} $*" >> "${logFile}"`); } + // The entrypoint's ownership probe is a first-mismatch find over the app + // home. An empty result models a fully node-owned tree (image-baked dir, + // healthy volume); a path models any uid OR gid mismatch anywhere in the + // tree (fresh root-owned mount, root-owned descendant, stale group after + // a GID-only remap). + writeStub("find", ids.homeMismatch ? `echo "$1/mismatched-entry"` : `true`); writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`); } @@ -79,17 +85,60 @@ describe("docker-entrypoint.sh", () => { }); it("remaps the node user and chowns /paperclip before gosu when root requests a different UID/GID", async () => { - installStubs({ uid: 0, gid: 0 }); + // The stubbed node uid stays 1000 while the stat probe reports the old + // ownership, modelling the post-remap mismatch that must trigger chown. + installStubs({ uid: 0, gid: 0, homeMismatch: true }); - const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001" }); + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(calls).toContain("usermod -o -u 1001 node"); expect(calls).toContain("groupmod -o -g 1001 node"); - expect(calls).toContain("chown -R node:node /paperclip"); + expect(calls).toContain(`chown -R node:node ${stubDir}`); expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); }); + it("chowns a root-owned home before gosu even with the default UID/GID (fresh volume mount)", async () => { + // A freshly mounted volume arrives root-owned and shadows the image's + // build-time chown; with no remap requested the old entrypoint dropped + // privileges onto an unwritable home and the server crashed on its + // first mkdir. + installStubs({ uid: 0, gid: 0, homeMismatch: true }); + + const { stdout, calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).toContain(`chown -R node:node ${stubDir}`); + expect(calls).not.toContain("usermod"); + expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); + }); + + it("repairs ownership on a GID-only remap (stale group on persisted descendants)", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true }); + + const { calls } = await runEntrypoint({ USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls).toContain("groupmod -o -g 1001 node"); + expect(calls).toContain(`chown -R node:node ${stubDir}`); + }); + + it("keeps a fully node-owned tree chown-free (no per-boot recursive chown)", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: false }); + + const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir }); + + expect(calls).not.toContain("chown"); + expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); + }); + + it("honours PAPERCLIP_HOME for the ownership probe", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true }); + + const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir }); + + expect(calls).toContain(`chown -R node:node ${stubDir}`); + }); + it("execs directly and silently when already running as the requested user (restricted PodSecurity)", async () => { installStubs({ uid: 1000, gid: 1000 });