From 3cff98fe5651eefd2463dc7b0098a624881c85cc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 28 Jul 2026 10:49:49 +0200 Subject: [PATCH] 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 /, 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) --- .changeset/553c3037.md | 9 +++ CLAUDE.md | 2 +- docs/architecture-invariants.md | 7 ++ src/web/routes/file-routes.ts | 58 +++++++++++----- test/routes/_route-test-utils.ts | 14 +++- test/routes/file-routes.test.ts | 112 +++++++++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 .changeset/553c3037.md diff --git a/.changeset/553c3037.md b/.changeset/553c3037.md new file mode 100644 index 00000000..10bdce07 --- /dev/null +++ b/.changeset/553c3037.md @@ -0,0 +1,9 @@ +--- +'aicodeman': patch +--- + +Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists. + +Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `/`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged. + +Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected. diff --git a/CLAUDE.md b/CLAUDE.md index 4c0c0d1d..9abf9837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) -**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and does not inherit the attachment confinement** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index c2af3081..1535187c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -80,6 +80,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. +⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`: + +1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists. +2. `Home` and `CASES_DIR` were unconditional roots. Per-user spaces live at `/`, which is **inside `homedir()`**, so a `Home` root alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets only `My Space` (their own `userSpacePath`) plus anything in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged. + +The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`. + ### Ultracode and workflow-run visualization **Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 58a8fc9e..b48f9e74 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -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 `/`, + * 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 { - 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 { - 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 ); diff --git a/test/routes/_route-test-utils.ts b/test/routes/_route-test-utils.ts index 1ea553ae..2a2facf7 100644 --- a/test/routes/_route-test-utils.ts +++ b/test/routes/_route-test-utils.ts @@ -20,18 +20,28 @@ export interface RouteTestHarness { * @param registerFn - The route registration function (e.g., registerSessionRoutes). * Uses `any` for ctx parameter because route functions expect typed port intersections * that MockRouteContext satisfies structurally but not nominally. - * @param ctxOptions - Optional overrides for the mock context + * @param ctxOptions - Optional overrides for the mock context. `authUser` stands + * in for what the auth middleware would attach in multi-user mode; without it + * `getAuthUser()` falls back to a synthetic admin, which passes every + * ownership check and would make a scoping test pass vacuously. */ export async function createRouteTestHarness( // eslint-disable-next-line @typescript-eslint/no-explicit-any registerFn: (app: FastifyInstance, ctx: any) => void, - ctxOptions?: { sessionId?: string } + ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } } ): Promise { const app = Fastify({ logger: false }); // Register cookie plugin — some routes access req.cookies await app.register(fastifyCookie); + if (ctxOptions?.authUser) { + const authUser = ctxOptions.authUser; + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: typeof authUser }).authUser = authUser; + }); + } + const ctx = createMockRouteContext(ctxOptions); registerFn(app, ctx); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index c1e1cde8..8f92cebb 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -169,6 +169,118 @@ describe('file-routes', () => { }); }); + // ========== Multi-user scoping for the filesystem picker ========== + // + // The picker is a SECOND file-serving surface and does not inherit the + // attachment guard's ownership scoping, so both of its endpoints have to do + // it themselves. Two distinct holes are covered here: + // 1. `sessionId` was used without an owner check, so any user could pin + // another user's workingDir as a browse root. + // 2. `Home` and `CASES_DIR` were unconditional roots, and per-user spaces + // live INSIDE homedir(), so Home alone exposed every other user's files. + describe('filesystem picker multi-user scoping', () => { + const SPACES = '/tmp/codeman-test-user-spaces'; + let prevMultiUser: string | undefined; + let prevSpaces: string | undefined; + + beforeEach(() => { + prevMultiUser = process.env.CODEMAN_MULTIUSER; + prevSpaces = process.env.CODEMAN_USER_SPACES_DIR; + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USER_SPACES_DIR = SPACES; + }); + + afterEach(() => { + if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prevMultiUser; + if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR; + else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces; + }); + + const harnessAs = (role: 'admin' | 'user', username: string) => + createRouteTestHarness(registerFileRoutes, { authUser: { username, role } }); + + it('404s a browse scoped to another user session instead of adopting its folder', async () => { + const scoped = await harnessAs('user', 'bob'); + scoped.ctx._session.owner = 'alice'; + try { + const res = await scoped.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?sessionId=${scoped.ctx._sessionId}`, + }); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND }); + // The decisive part: alice's folder must not have leaked in as a root. + expect(res.body).not.toContain(scoped.ctx._session.workingDir); + } finally { + await scoped.app.close(); + } + }); + + it('404s a preview scoped to another user session', async () => { + const scoped = await harnessAs('user', 'bob'); + scoped.ctx._session.owner = 'alice'; + try { + const res = await scoped.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${scoped.ctx._sessionId}&path=${encodeURIComponent( + `${scoped.ctx._session.workingDir}/notes.md` + )}`, + }); + + expect(res.statusCode).toBe(404); + } finally { + await scoped.app.close(); + } + }); + + it('confines a regular user to their own space, never Home or the shared cases dir', async () => { + const scoped = await harnessAs('user', 'bob'); + try { + mockedReaddir.mockResolvedValueOnce([] as never); + const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.roots).toEqual([{ label: 'My Space', path: `${SPACES}/bob` }]); + expect(body.data.path).toBe(`${SPACES}/bob`); + } finally { + await scoped.app.close(); + } + }); + + it("refuses to browse another user's space by absolute path", async () => { + const scoped = await harnessAs('user', 'bob'); + try { + const res = await scoped.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?path=${encodeURIComponent(`${SPACES}/alice/cases`)}`, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT }); + } finally { + await scoped.app.close(); + } + }); + + it('keeps the host-wide roots for a multi-user admin', async () => { + const scoped = await harnessAs('admin', 'root'); + try { + mockedReaddir.mockResolvedValueOnce([] as never); + const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' }); + + expect(res.statusCode).toBe(200); + const labels = JSON.parse(res.body).data.roots.map((root: { label: string }) => root.label); + expect(labels).toContain('Home'); + expect(labels).not.toContain('My Space'); + } finally { + await scoped.app.close(); + } + }); + }); + // ========== GET /api/filesystem/preview ========== describe('GET /api/filesystem/preview', () => {