fix(security): scope the filesystem path picker per user in multi-user mode

Both picker endpoints are a second file-serving surface, and they
inherited neither the attachment guard's confinement nor its ownership
scoping. Two separate holes:

1. `sessionId` contributes that session's workingDir as a browse root,
   but it was resolved straight off ctx.sessions/ctx.store with no owner
   check, unlike the nine other session-scoped handlers in this file. A
   non-admin could pin ANOTHER user's working directory as a root just
   by passing their session id, then list and preview underneath it. Now
   runs canAccessOwned and reports 404, which also avoids confirming
   that a session id exists.

2. `Home` and `CASES_DIR` were unconditional roots for every caller.
   Per-user spaces live at <USER_SPACES_DIR>/<username>, which is INSIDE
   homedir(), so the Home root alone exposed every other user's
   workspace. A multi-user non-admin now gets only their own
   userSpacePath plus anything explicitly listed in
   CODEMAN_FILE_PICKER_ROOTS. /mnt/d is dropped as well: a broad host
   mount should be an explicit operator decision in a multi-user
   deployment, and operators who want it can name it in that env var.

Admins and single-user mode keep the host-wide roots, so behavior is
unchanged unless CODEMAN_MULTIUSER is on (opt-in, off by default).

All three discriminating tests were verified to fail against the
previous code: browse and preview both returned 200 instead of 404, and
the roots came back as [Home, Codeman Cases, ...] instead of [My Space].
Full suite green, 3784 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-28 10:49:49 +02:00
parent bc232e5ff3
commit 3cff98fe56
6 changed files with 183 additions and 19 deletions
+42 -16
View File
@@ -30,6 +30,7 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
import {
CASES_DIR,
canAccessOwned,
@@ -323,33 +324,55 @@ function isBlockedPickerPath(path: string, blockedTrees: readonly string[], dire
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
}
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
const candidates: Array<{ label: string; path: string }> = [
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
if (!extraRoots) return [];
return extraRoots
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
}
/**
* Browse roots for the requesting identity.
*
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
* which is *inside* `homedir()`, so handing out a `Home` root would let any
* authenticated user browse and preview every other user's workspace. The
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
* that a multi-user deployment should not expose by default. Operators who
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
* which stays an explicit opt-in in both modes.
*/
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
const user = getAuthUser(req);
if (isMultiUserMode() && user.role !== 'admin') {
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
}
return [
{ label: 'Home', path: homedir() },
{ label: 'Codeman Cases', path: CASES_DIR },
{ label: 'WSL D:', path: '/mnt/d' },
...extraConfiguredPickerRoots(),
];
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
if (extraRoots) {
for (const [index, path] of extraRoots
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.entries()) {
candidates.push({ label: `Configured ${index + 1}`, path });
}
}
return candidates;
}
async function resolveFilesystemPickerRoots(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
sessionId?: string
): Promise<FilesystemBrowseRoot[]> {
const candidates = configuredFilesystemPickerRoots();
const candidates = configuredFilesystemPickerRoots(req);
if (sessionId) {
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
if (!session) {
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
// the other session-scoped handlers in this file. Without it a multi-user
// caller could pin ANOTHER user's `workingDir` as a browse root just by
// passing their sessionId. Report not-found rather than forbidden so the
// endpoint does not confirm that a session id exists.
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
@@ -394,10 +417,11 @@ function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, mess
async function resolveFilesystemPickerPath(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
requestedPath: string | undefined,
sessionId?: string
): Promise<ResolvedFilesystemPickerPath> {
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
if (roots.length === 0) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
}
@@ -545,6 +569,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
);
@@ -665,6 +690,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
);