mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(stacks): browse and edit mounted volume files in the explorer (#1403)
* feat(stacks): browse and edit mounted volume files in the explorer Reposition the stack file explorer around runtime configuration access: discover a stack's declared mounts and expose each as a safe, stack-scoped file root. The explorer opens on a Volumes group (bind mounts and named Docker volumes) by default, with the stack source directory as a secondary group, on a "Files & Volumes" tab. - Discover roots from the rendered effective compose model; resolve named volumes to their Docker name and browse/edit them through the hardened helper container, with bind mounts handled directly when reachable. - Re-derive the allowed roots server-side on every file operation and match the client root id against them, so a request can never address a path the stack did not declare. Block dangerous host mounts and binds that overlap Sencho's managed directories; reject writes to read-only mounts. - Thread an optional root id through the existing file endpoints and an opaque, parseable optimistic-concurrency token through read, conflict, and write, for both filesystem and helper backends. - Keep compose and env file protection on the stack source root only. * fix(stacks): theme the Files & Volumes root switcher Replace the raw native select in the file-root switcher with the design system Select component. The native control did not honour the dark theme, so the panel rendered white with unreadable text. The themed Select gives a dark popover with grouped Volumes / Stack source labels and disabled items. * fix(stacks): contain the bind-root probe and de-taint the file-op error log Gate the volume-root bind probe's realpath/stat behind a compose-base containment check (mirroring the storage host-path probe) so they never run on an unvalidated host path; a source outside the compose dir is unreachable in the containerized deployment anyway and is reported non-accessible without touching the filesystem. Log the helper-backed file-op failure through a constant format string with sanitized arguments instead of an interpolated template literal. * fix(stacks): inline the bind-probe containment guard at the fs sinks The wrapped containment predicate was not recognized as a path barrier, so the bind probe's realpath/stat still flagged as uncontrolled-data-in-path. Inline the path.resolve + startsWith check directly at each filesystem sink (and re-check the resolved canonical before stat, so a within-base symlink that resolves outside the compose dir is also rejected). * fix(stacks): harden file-root lifecycle, upload race, and helper errors Address review findings on the Files & Volumes feature: - Invalidate the file-root allowlist on stack create/delete/import/from-git (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a stack deleted and recreated under the same name cannot serve the old stack's roots from the 15s cache. - Use the atomic exclusive write for a non-overwrite upload so a file created by another writer after the existence check is not silently clobbered. - Let the helper's real cd errno through and map permission failures to 403 consistently across list/stat/read/write/mkdir/delete/pathKind, instead of reporting EACCES as 404/500; pathKind no longer reports a permission-denied parent as absent. - Document the realpath-then-open TOCTOU as a known, pre-existing limitation of every file op (O_NOFOLLOW is not viable because config volumes legitimately contain symlinks); the bind root is contained to the compose dir and the op requires stack:edit. - Docs: drop a missing screenshot reference and correct the protected-file delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
@@ -17,6 +17,21 @@ export interface FileEntry {
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional scope for a file-explorer operation. When `rootAbsDir` is set, the
|
||||
* operation resolves and is contained within that absolute directory instead of
|
||||
* the stack source dir, so the same primitives serve volume-aware bind-mount
|
||||
* roots. `protectedEnabled` (compose/.env protection) defaults to true and is
|
||||
* set false by the route for non-stack-source roots, where a file named
|
||||
* compose.yaml/.env is just an ordinary editable file. The caller is
|
||||
* responsible for pre-authorizing `rootAbsDir` (it may legitimately sit outside
|
||||
* the compose base dir); this service only enforces containment within it.
|
||||
*/
|
||||
export interface FileRootScope {
|
||||
rootAbsDir?: string;
|
||||
protectedEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the writable Sencho data directory (same one DatabaseService /
|
||||
* CryptoService use). Recomputed lazily so test harnesses that override
|
||||
@@ -1008,15 +1023,30 @@ export class FileSystemService {
|
||||
return MIME_MAP[ext] ?? 'text/plain';
|
||||
}
|
||||
|
||||
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
if (!isPathWithinBase(stackDir, this.baseDir)) {
|
||||
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
|
||||
/**
|
||||
* Resolve `relPath` within an arbitrary absolute root directory, applying the
|
||||
* same containment + symlink-escape protection used for stack-source paths.
|
||||
* Serves both the stack source dir (via resolveSafeStackPath) and volume-aware
|
||||
* bind-mount roots, which may legitimately resolve outside the compose base dir
|
||||
* (the caller pre-authorizes the root and passes its canonical realpath).
|
||||
*
|
||||
* KNOWN LIMITATION (TOCTOU): this realpath-validates the path, then the caller
|
||||
* opens/streams/writes it by name, so a process that can write inside the root
|
||||
* (e.g. a container writing its own bind-mounted config volume) could swap a
|
||||
* validated regular file for a symlink between this check and the open and
|
||||
* escape the root. Closing it fully requires per-component openat/O_RESOLVE
|
||||
* traversal; plain O_NOFOLLOW is not viable because config volumes
|
||||
* legitimately contain symlinks (e.g. nginx sites-enabled). This is a
|
||||
* pre-existing property of every FileSystemService file op (not specific to
|
||||
* volume roots); the bind root is contained to the compose dir and the op
|
||||
* requires stack:edit, which already grants equivalent host access via
|
||||
* compose. Tracked as a follow-up hardening, not a per-root regression.
|
||||
*/
|
||||
private async resolveSafePathWithin(rootAbsDir: string, relPath: string): Promise<string> {
|
||||
const target = relPath === '' ? rootAbsDir : path.resolve(rootAbsDir, relPath);
|
||||
|
||||
if (!isPathWithinBase(target, stackDir)) {
|
||||
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
|
||||
if (!isPathWithinBase(target, rootAbsDir)) {
|
||||
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
|
||||
let realTarget: string;
|
||||
@@ -1033,14 +1063,14 @@ export class FileSystemService {
|
||||
const parent = path.dirname(existing);
|
||||
if (parent === existing) {
|
||||
// Reached filesystem root without finding an existing path.
|
||||
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
|
||||
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
suffix.unshift(path.basename(existing));
|
||||
existing = parent;
|
||||
try {
|
||||
const realExisting = await fsPromises.realpath(existing);
|
||||
if (!isPathWithinBase(realExisting, stackDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
if (!isPathWithinBase(realExisting, rootAbsDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
}
|
||||
realTarget = path.join(realExisting, ...suffix);
|
||||
break;
|
||||
@@ -1052,15 +1082,40 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPathWithinBase(realTarget, stackDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
if (!isPathWithinBase(realTarget, rootAbsDir)) {
|
||||
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
|
||||
}
|
||||
|
||||
return realTarget;
|
||||
}
|
||||
|
||||
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
|
||||
const page = await this.listStackDirectoryPage(stackName, relPath, {});
|
||||
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
if (!isPathWithinBase(stackDir, this.baseDir)) {
|
||||
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
return this.resolveSafePathWithin(stackDir, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective path for an operation that may target the stack source
|
||||
* dir (default) or a pre-authorized bind-mount root (`scope.rootAbsDir`).
|
||||
*/
|
||||
private async resolveScopedPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
|
||||
return scope?.rootAbsDir !== undefined
|
||||
? this.resolveSafePathWithin(scope.rootAbsDir, relPath)
|
||||
: this.resolveSafeStackPath(stackName, relPath);
|
||||
}
|
||||
|
||||
/** Leaf-path variant of resolveScopedPath (does not follow a symlink leaf). */
|
||||
private async resolveScopedLeafPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
|
||||
return scope?.rootAbsDir !== undefined
|
||||
? this.resolveSafeLeafPathWithin(scope.rootAbsDir, relPath)
|
||||
: this.resolveSafeStackLeafPath(stackName, relPath);
|
||||
}
|
||||
|
||||
async listStackDirectory(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry[]> {
|
||||
const page = await this.listStackDirectoryPage(stackName, relPath, { scope });
|
||||
return page.entries;
|
||||
}
|
||||
|
||||
@@ -1074,9 +1129,10 @@ export class FileSystemService {
|
||||
async listStackDirectoryPage(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
opts: { limit?: number },
|
||||
opts: { limit?: number; scope?: FileRootScope },
|
||||
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
|
||||
const protectedEnabled = opts.scope?.protectedEnabled ?? true;
|
||||
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
|
||||
const total = dirents.length;
|
||||
|
||||
@@ -1102,7 +1158,7 @@ export class FileSystemService {
|
||||
type,
|
||||
size,
|
||||
mtime,
|
||||
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
|
||||
isProtected: protectedEnabled && PROTECTED_STACK_FILES.has(dirent.name),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1123,9 +1179,9 @@ export class FileSystemService {
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
maxBytes: number = 2 * 1024 * 1024,
|
||||
opts: { forceText?: boolean } = {},
|
||||
opts: { forceText?: boolean; scope?: FileRootScope } = {},
|
||||
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
|
||||
const mime = this.guessMime(safePath);
|
||||
|
||||
// Open once and stat+read through the same handle so the mtime returned to
|
||||
@@ -1166,9 +1222,10 @@ export class FileSystemService {
|
||||
|
||||
async streamStackFile(
|
||||
stackName: string,
|
||||
relPath: string
|
||||
relPath: string,
|
||||
scope?: FileRootScope,
|
||||
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
const stat = await fsPromises.stat(safePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
@@ -1253,9 +1310,9 @@ export class FileSystemService {
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
buffer: Buffer,
|
||||
opts?: { exclusive?: boolean },
|
||||
opts?: { exclusive?: boolean; scope?: FileRootScope },
|
||||
): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, opts?.scope);
|
||||
await this.writeStackFileAtomic(safePath, buffer, opts);
|
||||
}
|
||||
|
||||
@@ -1265,8 +1322,8 @@ export class FileSystemService {
|
||||
* so callers do not silently treat a malformed path as 'available for write'.
|
||||
* Callers should validate inputs upstream before invoking this helper.
|
||||
*/
|
||||
async pathKind(stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
async pathKind(stackName: string, relPath: string, scope?: FileRootScope): Promise<'file' | 'directory' | null> {
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
try {
|
||||
const stat = await fsPromises.lstat(safePath);
|
||||
if (stat.isDirectory()) return 'directory';
|
||||
@@ -1296,11 +1353,12 @@ export class FileSystemService {
|
||||
relPath: string,
|
||||
content: string,
|
||||
expectedMtimeMs: number | null,
|
||||
scope?: FileRootScope,
|
||||
): Promise<
|
||||
| { ok: true; mtimeMs: number }
|
||||
| { ok: false; currentMtimeMs: number; currentContent: string }
|
||||
> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
|
||||
if (expectedMtimeMs !== null) {
|
||||
@@ -1339,22 +1397,30 @@ export class FileSystemService {
|
||||
* target) for operations where following would mutate a file other than
|
||||
* the one the user clicked on.
|
||||
*/
|
||||
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
|
||||
private async resolveSafeLeafPathWithin(rootAbsDir: string, relPath: string): Promise<string> {
|
||||
if (relPath === '' || relPath === '.') {
|
||||
return this.resolveSafeStackPath(stackName, '');
|
||||
return this.resolveSafePathWithin(rootAbsDir, '');
|
||||
}
|
||||
const parentRel = path.dirname(relPath);
|
||||
const baseName = path.basename(relPath);
|
||||
if (!baseName || baseName === '.' || baseName === '..') {
|
||||
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
const safeParent = await this.resolveSafeStackPath(stackName, parentRel === '.' ? '' : parentRel);
|
||||
const safeParent = await this.resolveSafePathWithin(rootAbsDir, parentRel === '.' ? '' : parentRel);
|
||||
return path.join(safeParent, baseName);
|
||||
}
|
||||
|
||||
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
|
||||
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
|
||||
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
if (!isPathWithinBase(stackDir, this.baseDir)) {
|
||||
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
return this.resolveSafeLeafPathWithin(stackDir, relPath);
|
||||
}
|
||||
|
||||
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false, scope?: FileRootScope): Promise<void> {
|
||||
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
|
||||
|
||||
// Branch on whether the leaf is a symlink BEFORE following it. Deleting
|
||||
// a symlink should remove the link entry the user clicked on; following
|
||||
@@ -1390,8 +1456,8 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<void> {
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
await fsPromises.mkdir(safePath, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -1403,11 +1469,13 @@ export class FileSystemService {
|
||||
* the delete/chmod policy. fs.rename fails with EXDEV across a filesystem
|
||||
* boundary (e.g. a bind-mounted subdirectory); the route surfaces that as a 409.
|
||||
*/
|
||||
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
|
||||
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
|
||||
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
|
||||
const fromPath = await this.resolveSafeStackLeafPath(stackName, fromRel);
|
||||
const toPath = await this.resolveSafeStackLeafPath(stackName, toRel);
|
||||
async renameStackPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise<void> {
|
||||
if (scope?.protectedEnabled ?? true) {
|
||||
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
|
||||
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
|
||||
}
|
||||
const fromPath = await this.resolveScopedLeafPath(stackName, fromRel, scope);
|
||||
const toPath = await this.resolveScopedLeafPath(stackName, toRel, scope);
|
||||
const toName = path.basename(toPath);
|
||||
if (!toName || toName === '.' || toName === '..') {
|
||||
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
|
||||
@@ -1437,19 +1505,19 @@ export class FileSystemService {
|
||||
await fsPromises.rename(fromPath, toPath);
|
||||
}
|
||||
|
||||
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
async getStackEntryMode(stackName: string, relPath: string, scope?: FileRootScope): Promise<{ mode: number; octal: string }> {
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
const stat = await fsPromises.stat(safePath);
|
||||
const mode = stat.mode & 0o777;
|
||||
return { mode, octal: mode.toString(8).padStart(3, '0') };
|
||||
}
|
||||
|
||||
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
|
||||
async chmodStackPath(stackName: string, relPath: string, mode: number, scope?: FileRootScope): Promise<void> {
|
||||
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
||||
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
|
||||
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
|
||||
|
||||
// chmod on a symlink is rejected. Following the link would silently
|
||||
// mutate permissions on a file with a different name than the entry the
|
||||
@@ -1466,8 +1534,8 @@ export class FileSystemService {
|
||||
await fsPromises.chmod(leafPath, mode);
|
||||
}
|
||||
|
||||
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
async statStackEntry(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry> {
|
||||
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
|
||||
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
|
||||
const stat = await fsPromises.lstat(safePath);
|
||||
const name = path.basename(safePath);
|
||||
@@ -1483,7 +1551,7 @@ export class FileSystemService {
|
||||
type,
|
||||
size: stat.isDirectory() ? 0 : stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
isProtected: PROTECTED_STACK_FILES.has(name),
|
||||
isProtected: (scope?.protectedEnabled ?? true) && PROTECTED_STACK_FILES.has(name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user