Files
Codeman/test/multiuser-auth.test.ts
Codeman maintainer ccb3afc9ee fix(multiuser): close cross-user web-layer scoping holes found in review
The opt-in multi-user feature's only enforcement is web-layer scoping
(all sessions share one OS account). An adversarial review found 8 critical
+ 7 high cross-user holes that defeated it, plus mediums; all fixed here.
Single-user (flag-off) behavior stays byte-identical apart from documented
consistency deltas.

Ownership / confinement:
- DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail
- quick-start, cron (create+fire), scheduled runs confine workingDir to the
  owner's space; case link/docker-link/docker-import confine the host path
- resolveCasePath no longer resolves linked cases for non-admins; foreign
  remote/docker cases are skipped (fall through to the caller's own local case)
- history, subagents/workflows, mux-sessions, orchestrator, cron run-history,
  away-digest, and remote/docker host reads are owner- or admin-scoped

Permission policy (section 6.3):
- non-granted users are downgraded at every spawn site incl. legacy
  /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire
  gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed

Auth / store:
- verify-first login throttle (a correct password is never locked out),
  /ws terminal subject to the change-password lockbox, cookie fast-path
  re-validates identity live, role/grant changes revoke sessions, admin delete
  runs the last-admin guard before any teardown
- users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad
  read can't overwrite all accounts; unique per-process temp write path

Event streams:
- debounced session:updated + batched task:updated, clipboard, and push
  notifications route by owner (fail closed); getLightState hides machine-wide
  globalStats from non-admins

Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint,
and test:ci all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:33:12 +02:00

208 lines
8.5 KiB
TypeScript

/**
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
*
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
* identity, wrong-password / disabled-user rejection, the mustChangePassword
* lockbox + self-service change, per-account rate limiting, and QR identity binding
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
*
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3170;
const RATE_PORT = 3171;
function basic(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
function cookieFrom(res: Response): string | null {
const raw = res.headers.get('set-cookie');
const m = raw?.match(/codeman_session=([^;]+)/);
return m ? `codeman_session=${m[1]}` : null;
}
let server: WebServer;
let rateServer: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
beforeAll(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_PASSWORD',
'CODEMAN_USERNAME',
]) {
saved[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = dataDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
// Disable carol after creation.
const { updateUser } = await import('../src/user-store.js');
await updateUser('carol', { disabled: true });
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
await rateServer?.stop().catch(() => {});
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
const url = (p: string) => `http://localhost:${PORT}${p}`;
describe('multi-user auth', () => {
it('rejects unauthenticated requests', async () => {
const res = await fetch(url('/api/status'));
expect(res.status).toBe(401);
});
it('authenticates a valid user and issues an identity cookie', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
expect(res.status).toBe(200);
const cookie = cookieFrom(res);
expect(cookie).toBeTruthy();
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
expect(me.status).toBe(200);
const body = await me.json();
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
});
it('reports role for a regular user', async () => {
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
expect(res.status).toBe(200);
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
});
it('rejects a wrong password', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
expect(res.status).toBe(401);
});
it('rejects a disabled user even with the correct password', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
expect(res.status).toBe(401);
});
it('is case-insensitive on the username', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
expect(res.status).toBe(200);
});
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
expect(authed.status).toBe(403);
const body = await authed.json();
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
const cookie = cookieFrom(authed);
expect(cookie).toBeTruthy();
// /api/me is exempt.
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
expect(me.status).toBe(200);
expect((await me.json()).data.mustChangePassword).toBe(true);
// Wrong current password is refused.
const bad = await fetch(url('/api/me/password'), {
method: 'POST',
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
});
expect(bad.status).toBe(403);
// Correct change clears the flag.
const ok = await fetch(url('/api/me/password'), {
method: 'POST',
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
});
expect(ok.status).toBe(200);
// Same cookie now reaches a non-exempt route.
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
expect(after.status).toBe(200);
});
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
rateServer = new WebServer(RATE_PORT, false, true);
await rateServer.start();
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
expect(res.status).toBe(401);
}
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
// throttle) — the accumulated failures can never lock the account out — and success
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
expect(good.status).toBe(200);
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
expect(afterReset.status).toBe(401);
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
let limited = false;
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
limited = res.status === 429;
}
expect(limited).toBe(true);
});
});
describe('QR token identity (tunnel-manager)', () => {
it('binds a minted token to a user and returns it on consume (single-use)', () => {
const tm = new TunnelManager();
const code = tm.mintUserToken('alice');
expect(code).toHaveLength(6);
const first = tm.consumeTokenWithIdentity(code);
expect(first).toEqual({ ok: true, username: 'alice' });
// single-use
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
});
it('unknown code is rejected', () => {
const tm = new TunnelManager();
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
});
});