mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +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:
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser } from '../../user-store.js';
|
||||
import { getAuthUser } from '../route-helpers.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -224,14 +224,16 @@ export function registerSystemRoutes(
|
||||
}
|
||||
|
||||
// Resolve the role for the bound user (disabled/deleted users fail closed).
|
||||
let identity: { username: string; role: 'admin' | 'user' } | undefined;
|
||||
// Carry the bound user's real mustChangePassword flag out of this block so the
|
||||
// minted cookie enforces the lockbox instead of hardcoding false.
|
||||
let identity: { username: string; role: 'admin' | 'user'; mustChangePassword: boolean } | undefined;
|
||||
if (multiUser && consumed.username) {
|
||||
const user = await findUser(consumed.username);
|
||||
if (!user || user.disabled) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
identity = { username: user.username, role: user.role };
|
||||
identity = { username: user.username, role: user.role, mustChangePassword: !!user.mustChangePassword };
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
@@ -244,7 +246,7 @@ export function registerSystemRoutes(
|
||||
method: 'qr',
|
||||
username: identity?.username,
|
||||
role: identity?.role,
|
||||
mustChangePassword: false,
|
||||
mustChangePassword: !!identity?.mustChangePassword,
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
@@ -493,23 +495,52 @@ export function registerSystemRoutes(
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
// Multi-user: scope the digest's aggregated activity to sessions the caller
|
||||
// owns (canAccessOwned is a no-op allow-all for admins/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values())
|
||||
.filter((session) => canAccessOwned(user, session.owner))
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
// Run-summary trackers are keyed by Codeman session id → filter by that session's owner.
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.entries())
|
||||
.filter(([id]) => canAccessOwned(user, ctx.sessions.get(id)?.owner))
|
||||
.map(([, tracker]) => tracker.getSummary());
|
||||
|
||||
// Map each subagent's Claude conversation id back to its owning session so the
|
||||
// recent-subagent lookback is owner-scoped too (fails closed when unattributable).
|
||||
const ownerByClaudeSessionId = new Map<string, string | undefined>();
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.claudeSessionId) ownerByClaudeSessionId.set(s.claudeSessionId, s.owner);
|
||||
}
|
||||
const subagents = subagentWatcher
|
||||
.getRecentSubagents(60)
|
||||
.filter((sa) => canAccessOwned(user, ownerByClaudeSessionId.get(sa.sessionId))) as AwayDigestSubagent[];
|
||||
|
||||
// Multi-user: the lifecycle log and daily token stats carry no owner, so scope them
|
||||
// for a non-admin: keep only lifecycle entries attributable to an owned LIVE session
|
||||
// (fail closed — an ended session's owner can't be resolved, so it is dropped rather
|
||||
// than leaked), and withhold the machine-wide daily token totals entirely (they can't
|
||||
// be per-user attributed, same as globalStats in #29). Admins/single-user keep all
|
||||
// (canAccessOwned allow-all, role check false → byte-identical).
|
||||
const scopedLifecycle = lifecycleEntries.filter((e) =>
|
||||
canAccessOwned(user, ctx.sessions.get(e.sessionId ?? '')?.owner)
|
||||
);
|
||||
const nonAdminScoped = isMultiUserMode() && user.role !== 'admin';
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
lifecycleEntries: scopedLifecycle,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
dailyTokenStats: nonAdminScoped ? [] : ctx.store.getDailyStats(30),
|
||||
subagents,
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
@@ -827,7 +858,10 @@ export function registerSystemRoutes(
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
app.get('/api/workflows', async (req, reply) => {
|
||||
// Multi-user stopgap: these aggregates are process-wide (no owner concept), so
|
||||
// restrict cross-user reads to admins (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
@@ -836,7 +870,9 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
app.get('/api/workflows/:runId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user run detail is admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
@@ -847,7 +883,10 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
app.get('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: the global subagent list spans all users → admin-only
|
||||
// (no-op allow-all in single-user mode). Per-session variant below stays scoped.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const subagents = minutes
|
||||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||||
@@ -862,7 +901,9 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId', async (req) => {
|
||||
app.get('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user subagent metadata is admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -871,7 +912,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: info };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||||
app.get('/api/subagents/:agentId/transcript', async (req, reply) => {
|
||||
// Multi-user stopgap: transcript CONTENT of any user's subagent is admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||||
@@ -885,7 +929,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: transcript };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents/:agentId', async (req) => {
|
||||
app.delete('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: killing any user's subagent is a cross-user write → admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -899,12 +946,16 @@ export function registerSystemRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||||
});
|
||||
|
||||
app.post('/api/subagents/cleanup', async () => {
|
||||
app.post('/api/subagents/cleanup', async (req, reply) => {
|
||||
// Multi-user stopgap: process-wide cleanup affects every user → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const removed = subagentWatcher.cleanupNow();
|
||||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents', async () => {
|
||||
app.delete('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: clearing ALL users' subagents is a cross-user write → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const cleared = subagentWatcher.clearAll();
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user