mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
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>
This commit is contained in:
@@ -221,7 +221,10 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Respawn controller not found');
|
||||
// respawn/stop now owner-gates via findSessionOrFail first (multi-user #18), so a
|
||||
// non-existent session id 404s as "Session ... not found" (same not-found semantics,
|
||||
// matching the sibling start/config/enable handlers).
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle updating config on non-existent session', async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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);
|
||||
|
||||
@@ -159,17 +160,32 @@ describe('multi-user auth', () => {
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rate-limits repeated failures for an account', async () => {
|
||||
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}`;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 11th attempt (even with correct creds) is rate-limited.
|
||||
const limited = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(limited.status).toBe(429);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ describe('scheduled-routes', () => {
|
||||
// Bare { run } return (envelope-wrapped to { success:true, data:{ run } }
|
||||
// in production; harness sees the bare return).
|
||||
expect(body.run).toBeDefined();
|
||||
// Should default to 60 minutes
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
|
||||
// Should default to 60 minutes; 4th arg is the multi-user owner (undefined in single-user).
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user