per-user linux accounts, stage 1: the account and the privilege drop
A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.
Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.
sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.
Three bugs that only a real run with a real useradd could find:
- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
umask (755) and only the account being created got confined. An unlistable parent is
no protection when the child is world-readable and emails are guessable. The skeleton
is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
an owner token and bypass every capability check. Now a boot check that refuses to
start with OFFICER_OS_USERS on while any .env in the project root is group- or
world-readable.
Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv, OS_USER_PREFIX } from './os-user';
|
||||
|
||||
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
|
||||
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
|
||||
// and is exercised by hand — see docs/per-user-linux-accounts.md.
|
||||
|
||||
describe('Bun.spawn uid', () => {
|
||||
// THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop
|
||||
// written the obvious way runs as the parent while looking correct. This test exists so that:
|
||||
//
|
||||
// - nobody "simplifies" runAs into a uid option, and
|
||||
// - if Bun ever implements it, this fails and tells us we may.
|
||||
//
|
||||
// Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast,
|
||||
// which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a
|
||||
// hurry. The runtime is what silently accepts it, and the runtime is what this pins.
|
||||
//
|
||||
// Skipped when running as root, where setuid would actually be permitted and the observation changes.
|
||||
test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => {
|
||||
const nobody = 65534;
|
||||
expect(process.getuid?.()).not.toBe(nobody);
|
||||
|
||||
const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as {
|
||||
stdout: 'pipe';
|
||||
stderr: 'pipe';
|
||||
};
|
||||
const proc = Bun.spawn(['id', '-u'], options);
|
||||
const seen = (await new Response(proc.stdout).text()).trim();
|
||||
const code = await proc.exited;
|
||||
|
||||
// If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody`
|
||||
// (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news.
|
||||
expect(code).toBe(0);
|
||||
expect(seen).toBe(String(process.getuid?.()));
|
||||
expect(seen).not.toBe(String(nobody));
|
||||
});
|
||||
});
|
||||
|
||||
/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */
|
||||
const canSudo = await (async () => {
|
||||
const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' });
|
||||
return (await proc.exited) === 0;
|
||||
})();
|
||||
|
||||
describe('runAsArgv', () => {
|
||||
test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => {
|
||||
expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([
|
||||
'sudo',
|
||||
'-n',
|
||||
'setpriv',
|
||||
'--reuid=officer_ana',
|
||||
'--regid=officer_ana',
|
||||
'--init-groups',
|
||||
'--reset-env',
|
||||
'--',
|
||||
'zsh',
|
||||
'-i',
|
||||
]);
|
||||
});
|
||||
|
||||
// --init-groups and --reset-env are not decoration: without the first the process keeps the owner's
|
||||
// supplementary groups, and without the second it inherits everything Bun loaded from .env.
|
||||
test('never omits --init-groups or --reset-env', () => {
|
||||
const argv = runAsArgv('officer_ana', ['true']);
|
||||
expect(argv).toContain('--init-groups');
|
||||
expect(argv).toContain('--reset-env');
|
||||
});
|
||||
|
||||
test('refuses an empty user or command rather than running as the owner', () => {
|
||||
expect(() => runAsArgv('', ['true'])).toThrow();
|
||||
expect(() => runAsArgv('officer_ana', [])).toThrow();
|
||||
});
|
||||
|
||||
// Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be
|
||||
// created, which still exercises sudo, setpriv, initgroups and the env reset.
|
||||
//
|
||||
// Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at
|
||||
// all, and a red test there would say "the code is broken" instead of "this host is not set up".
|
||||
test.skipIf(!canSudo)('runs the command as the requested account', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
const err = (await new Response(proc.stderr).text()).trim();
|
||||
expect(await proc.exited, `setpriv failed: ${err}`).toBe(0);
|
||||
expect(out).toBe(String(me));
|
||||
});
|
||||
|
||||
// The property the whole feature rests on: the platform's environment does not cross the boundary. This
|
||||
// process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT
|
||||
// secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them.
|
||||
test.skipIf(!canSudo)('does not pass the platform environment through', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), {
|
||||
env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
expect(await proc.exited).toBe(0);
|
||||
expect(out).toBe('[unset]');
|
||||
});
|
||||
|
||||
// …and HOME is the target account's, not the caller's. This is what makes a member's shell and their
|
||||
// agent's config land in their own directory rather than the owner's.
|
||||
test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => {
|
||||
const me = process.getuid?.() ?? 0;
|
||||
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
expect(await proc.exited).toBe(0);
|
||||
expect(out).toBeTruthy();
|
||||
// Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it.
|
||||
const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' });
|
||||
expect(out).toBe((await new Response(passwd.stdout).text()).trim());
|
||||
});
|
||||
});
|
||||
|
||||
describe('osUserNameFor', () => {
|
||||
test('prefixes so it cannot collide with a system account', () => {
|
||||
expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe(`${OS_USER_PREFIX}ana`);
|
||||
});
|
||||
|
||||
test('falls back to the email local part when there is no username', () => {
|
||||
expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe(`${OS_USER_PREFIX}ana.silva`);
|
||||
});
|
||||
|
||||
test('sanitises what useradd would refuse', () => {
|
||||
expect(osUserNameFor({ username: 'Ana Silva!', email: 'a@b.com' })).toBe(`${OS_USER_PREFIX}ana_silva_`);
|
||||
});
|
||||
|
||||
// The prefix can push an already-32-char sanitised name over the limit, and useradd rejects the whole
|
||||
// name rather than truncating it.
|
||||
test('stays within the 32-character limit', () => {
|
||||
const name = osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' });
|
||||
expect(name.length).toBe(32);
|
||||
expect(name.startsWith(OS_USER_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
test('a username that tries to shadow root is still prefixed', () => {
|
||||
expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe(`${OS_USER_PREFIX}root`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReadableSecrets', () => {
|
||||
test('reports a group- or world-readable .env, and nothing when it is 600', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
|
||||
const env = join(dir, '.env');
|
||||
await writeFile(env, 'JWT_SECRET=x\n');
|
||||
|
||||
await chmod(env, 0o644);
|
||||
expect(await findReadableSecrets(dir)).toEqual([env]);
|
||||
|
||||
// Group-only still counts: a member's supplementary groups are not ours to predict.
|
||||
await chmod(env, 0o640);
|
||||
expect(await findReadableSecrets(dir)).toEqual([env]);
|
||||
|
||||
await chmod(env, 0o600);
|
||||
expect(await findReadableSecrets(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an absent .env is not a finding', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
|
||||
expect(await findReadableSecrets(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user