mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
fix(files): harden the outside-worktree root after review
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the session folder, and an outside worktree is published as outsideWorktree and walked only with root=live-any. Refuse a symlinked .git folder as the main worktree, sensitive files inside a followed worktree, and any outside worktree while attachment confinement is on or around a blocked tree. The file routes re-check the guard settings on every request, and a throwing rule check refuses. The panel keeps its Back to worktree button while pinned to the session folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9b79097b0
commit
8d33f53fd0
28 files changed
+856
-198
No files matched your search
@@ -49,7 +49,13 @@ import {
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import {
|
||||
isBlockedAttachmentDirectory,
|
||||
isBlockedAttachmentPath,
|
||||
isOutsideWorktreeRootRefused,
|
||||
isUnderTree,
|
||||
loadAttachmentGuardConfig,
|
||||
} from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
@@ -660,6 +666,19 @@ type FileTargetResolution =
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The scope with an outside worktree (#765) dropped when the attachment guard refuses its root
|
||||
* right now. `findOutsideWorktree()` judged the guard when the pane moved, and an admin can turn
|
||||
* on workspace confinement or block the folder afterwards, so every request judges it again. A
|
||||
* scope without an outside worktree comes back unchanged and reads no settings.
|
||||
*/
|
||||
async function withGuardedOutsideWorktree<T extends { liveWorktree?: LiveWorktree }>(scope: T): Promise<T> {
|
||||
const worktree = scope.liveWorktree;
|
||||
if (!worktree?.outside) return scope;
|
||||
if (!isOutsideWorktreeRootRefused(worktree.path, await loadAttachmentGuardConfig())) return scope;
|
||||
return { ...scope, liveWorktree: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request's `?path=` against the session's file scope.
|
||||
*
|
||||
@@ -669,7 +688,7 @@ type FileTargetResolution =
|
||||
*/
|
||||
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
|
||||
if (!scope.remote) {
|
||||
const validated = validateLocalSessionFilePath(scope, filePath);
|
||||
const validated = validateLocalSessionFilePath(await withGuardedOutsideWorktree(scope), filePath);
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -889,7 +908,8 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `root=live` listing may start at a worktree OUTSIDE `workingDir` (`liveWorktree.outside`).
|
||||
* Whether a `root=live-any` listing may start at a worktree OUTSIDE `workingDir` (`outside: true`,
|
||||
* published as `outsideWorktree`).
|
||||
* `findOutsideWorktree()` recorded its real path, and a symlink can take the folder's place, or a
|
||||
* parent's, without moving the pane, so the folder must still resolve to exactly that path. Probed
|
||||
* first (#516).
|
||||
@@ -971,11 +991,7 @@ function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>):
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
return directory ? isBlockedAttachmentDirectory(path, blockedTrees) : isBlockedAttachmentPath(path, blockedTrees);
|
||||
}
|
||||
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
@@ -1735,19 +1751,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
// A worktree OUTSIDE `workingDir` (`liveWorktree.outside`) is a second root: walked only while
|
||||
// it still resolves to the real path recorded for it, and listed with ABSOLUTE `path`s, since a
|
||||
// relative one resolves against `workingDir` on every other route. Those routes confine an
|
||||
// absolute path inside it to it (`validateLocalSessionFilePath`).
|
||||
const liveWorktree = session.liveWorktree;
|
||||
// A worktree OUTSIDE `workingDir` (the session's `outsideWorktree` on the wire) is a second
|
||||
// root, and walking it is opt-in: only `root=live-any` does. `root=live` never walks it (every
|
||||
// `path` stays relative to `workingDir`), so with an outside worktree it walks `workingDir`. The outside
|
||||
// walk happens only while the folder still resolves to the real path recorded for it, and it
|
||||
// lists ABSOLUTE `path`s, since a relative one resolves against `workingDir` on every other
|
||||
// route. Those routes confine an absolute path inside it to it (`validateLocalSessionFilePath`).
|
||||
const liveWorktree = (await withGuardedOutsideWorktree({ liveWorktree: session.liveWorktree })).liveWorktree;
|
||||
const walksOutside =
|
||||
root === 'live' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
root === 'live-any' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
const walksInside =
|
||||
root === 'live' &&
|
||||
(root === 'live' || root === 'live-any') &&
|
||||
!!liveWorktree &&
|
||||
!liveWorktree.outside &&
|
||||
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
|
||||
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
|
||||
// Only `root=live-any` reports an outside worktree: it is the client that opted in to it.
|
||||
// A listing keeps reporting a worktree the guard refuses to walk, or the panel would reload on
|
||||
// every session update.
|
||||
const sessionWorktree = session.liveWorktree;
|
||||
const reportedWorktree =
|
||||
sessionWorktree && (!sessionWorktree.outside || root === 'live-any') ? sessionWorktree : undefined;
|
||||
const nodePath = (fullPath: string): string => (walksOutside ? fullPath : fullPath.slice(workingDir.length + 1));
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
@@ -1876,7 +1900,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
success: true,
|
||||
data: {
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
...(reportedWorktree && { worktree: reportedWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1967,7 +1991,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
success: true,
|
||||
data: {
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
...(reportedWorktree && { worktree: reportedWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
@@ -2269,7 +2293,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||
}
|
||||
|
||||
const validated = validateLocalSessionFilePath(session, body.path);
|
||||
const validated = validateLocalSessionFilePath(
|
||||
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
|
||||
body.path
|
||||
);
|
||||
if (!validated) {
|
||||
// Covers missing files, traversal, and symlink escapes alike — a write
|
||||
// target that fails confinement is reported identically to a missing
|
||||
@@ -3014,7 +3041,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateLocalSessionFilePath(session, filePath);
|
||||
const validated = validateLocalSessionFilePath(
|
||||
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
|
||||
filePath
|
||||
);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
|
||||
@@ -33,7 +33,14 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateLocalSessionFilePath } from '../route-helpers.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
validateLocalSessionFilePath,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import { HtmlViewOpenSchema } from '../schemas.js';
|
||||
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
@@ -176,8 +183,14 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
|
||||
// A path the live-worktree root admitted (an outside worktree, a second served root) is
|
||||
// inside that root but not inside `workingDir`: it still answers to the attachment guard.
|
||||
let viaOutsideWorktree = false;
|
||||
if (inWorkspace) {
|
||||
resolvedPath = inWorkspace.resolvedPath;
|
||||
if (!validateSessionFilePath(session.workingDir, requestedPath)) {
|
||||
viaOutsideWorktree = true;
|
||||
}
|
||||
} else {
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
@@ -189,6 +202,9 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// One gate for every page outside `workingDir`: an external file and one in an outside worktree.
|
||||
if (!inWorkspace || viaOutsideWorktree) {
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
@@ -233,7 +249,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
return;
|
||||
}
|
||||
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace || viaOutsideWorktree);
|
||||
return {
|
||||
success: true,
|
||||
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
|
||||
|
||||
Reference in new issue
Block a user