diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index da09d2fd..8e57bc5d 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -297,11 +297,46 @@ export async function confineUserTree(params: { try { if (!existsSync(home)) await mkdir(home, { recursive: true }); - // Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the - // directory and learn every other member's email address. + // Traversable, not listable — for everyone EXCEPT the members themselves, who are named below. + // + // ── Why "not listable" could not be kept ── + // + // 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program + // that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution + // cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`: + // + // openat("/home/pastilhas/officerdev/") = 6 + // openat("/home/pastilhas/officerdev/data/") = -1 EACCES + // openat("/home/pastilhas/officerdev/data//") = -1 EACCES + // + // and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds; + // it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so + // this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project + // being mysteriously unbuildable from a member's shell. + // + // The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email + // addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling + // is 700 and owned by the service user. What is given up is the account list, not any account's data. + // + // Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box. await chmod(DATA_PATH, 0o711); await chmod(accountDir, 0o711); + // After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is + // `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to + // traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not + // silently re-break the first. + const uidEntry = `u:${params.uid}:rx`; + const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]); + if (!openUp.ok) { + return { + ok: false, + error: + `could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` + + `Without it their own tooling cannot resolve paths inside their home.`, + }; + } + // Every sibling of `home` is the platform's. 700 means traversal alone does not open them. const entries = await readdir(accountDir, { withFileTypes: true }); for (const entry of entries) {