linux accounts use the chosen username, and refuse to take one over
Two changes, and the second is what makes the first safe. The officer_ prefix is gone: a member's account is the username the owner typed, so whoami says who they are and a commit from their checkout is attributed to something recognisable. Measured first — useradd on this host accepts everything validateUsername permits, including dots, hyphens, underscores and uppercase. The prefix was also load-bearing, though, and not for looks. ensureOsUser REUSES an existing account, which is what makes it re-runnable, and that was safe by construction while only we created officer_* names. Unprefixed, adoption becomes the dangerous path: a platform account named root would have found root in passwd, and every runAs for that member would have been a root shell. So adoption now requires the existing account's passwd home to be exactly the home we are about to confine — that is what makes it ours — and any uid below 1000 is refused outright. Verified: root and daemon refused as system accounts, and the owner's own username refused by name with its real home quoted back. Also, the ancestor trap from the first real install. A member's home is under DATA_PATH, which is under the OWNER'S home, and /home/<owner> is 750 on Debian and Ubuntu — so every mode bit on the account tree was right, the directory existed, and the member still could not reach it for want of x four levels up. It surfaced as "ssh-keygen: Could not stat …/.ssh: Permission denied", which points at the wrong thing entirely. firstUntraversableAncestor now walks the chain as the member before anything uses the home, and the error names the directory and the chmod. The dev machine was already 751, and the probe used /tmp, so it never crossed the ancestor that mattered. Worth remembering as a shape of mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+17
-16
@@ -2,7 +2,7 @@ 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';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv } 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
|
||||
@@ -119,28 +119,29 @@ describe('runAsArgv', () => {
|
||||
});
|
||||
|
||||
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`);
|
||||
// The chosen username, verbatim — so `whoami` in a member's terminal says who they are. Measured on this
|
||||
// host: useradd accepts dots, hyphens, underscores and uppercase, i.e. everything validateUsername lets
|
||||
// through.
|
||||
test('uses the chosen username as-is', () => {
|
||||
expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe('ana');
|
||||
expect(osUserNameFor({ username: 'ana.silva', email: 'a@b.com' })).toBe('ana.silva');
|
||||
expect(osUserNameFor({ username: 'Ana-Silva_2', email: 'a@b.com' })).toBe('Ana-Silva_2');
|
||||
});
|
||||
|
||||
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`);
|
||||
expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe('ana.silva');
|
||||
expect(osUserNameFor({ username: ' ', email: 'Ana.Silva@example.com' })).toBe('ana.silva');
|
||||
});
|
||||
|
||||
test('sanitises what useradd would refuse', () => {
|
||||
expect(osUserNameFor({ username: 'Ana Silva!', email: 'a@b.com' })).toBe(`${OS_USER_PREFIX}ana_silva_`);
|
||||
test('stays within the 32-character limit useradd enforces', () => {
|
||||
expect(osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' })).toHaveLength(32);
|
||||
});
|
||||
|
||||
// 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`);
|
||||
// No longer defended by a prefix, so it must be defended by adoption rules instead: `ensureOsUser`
|
||||
// refuses a name whose existing passwd home is not the one we are about to confine, and refuses any uid
|
||||
// below 1000 outright. This test records that the NAME itself is no longer the protection.
|
||||
test('does not neutralise a dangerous name — that is ensureOsUser-s job now', () => {
|
||||
expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user