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:
Anso
2026-06-21 18:16:20 -04:00
committed by GitHub
parent b611f41872
commit b9d8e9f490
24 changed files with 1986 additions and 205 deletions
+265 -10
View File
@@ -1,18 +1,32 @@
import { Writable } from 'stream';
import path from 'path';
import { createHash } from 'crypto';
import DockerController from './DockerController';
const HELPER_IMAGE = 'alpine:3.20';
const VOLUME_MOUNT = '/v';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
// Named-volume downloads are bounded (not chunk-streamed): the helper output is
// buffered up to this size and a larger file is rejected rather than silently
// truncated. The bound matches the upload limit so the in-memory footprint is no
// worse than the existing multipart upload path.
const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 30_000;
// Containment guard run inside the helper after every `cd`. Even though
// sanitizeRelPath strips `..`, a symlinked directory component could redirect
// `cd` outside the mounted volume; `pwd -P` resolves symlinks so we can assert
// the working directory is still under the volume root before touching anything.
const ROOT_GUARD =
`case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "path escapes volume root" >&2; exit 7 ;; esac`;
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
// it before iterating, so user input never lands as an argv element to a
// command that might interpret it as a flag.
const LIST_SCRIPT = `set -e
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
cd -- "$1" || exit 1
${ROOT_GUARD}
for entry in * .[!.]* ..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
@@ -25,8 +39,15 @@ for entry in * .[!.]* ..?*; do
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
done`;
// Contain the parent before statting the leaf: cd into the leaf's directory and
// assert (via pwd -P) it is still inside the volume, so a symlinked path
// component cannot make stat report on a file outside the mount.
const STAT_SCRIPT = `set -e
target="$1"
p="$1"
d=$(dirname -- "$p"); b=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
target="$b"
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
elif [ -d "$target" ]; then t=d; link=""
@@ -38,6 +59,62 @@ mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
name=$(basename -- "$target")
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
// --- mutation + probe scripts (all contain the parent, then act on the leaf) ---
// $1 = relative path. New file → owned by the helper user (65534); existing file
// → in-place truncate (`>` keeps the inode, so owner/mode are preserved). A
// symlink leaf is refused so a write never follows a link out of the volume.
const WRITE_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -L "$f" ] && { echo "refusing to write through a symlink" >&2; exit 8; }
[ -d "$f" ] && { echo "target is a directory" >&2; exit 9; }
cat > "$f"`;
// $1 = relative path of the new directory; its parent must already exist.
const MKDIR_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
mkdir -- "$f"`;
// $1 = relative path, $2 = "1" for recursive directory delete. Removing a symlink
// leaf is allowed (rm unlinks the link itself, never its target).
const DELETE_SCRIPT = `set -e
p="$1"; recursive="$2"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -e "$f" ] || [ -L "$f" ] || { echo "no such path" >&2; exit 1; }
if [ -d "$f" ] && [ ! -L "$f" ]; then
if [ "$recursive" = "1" ]; then rm -rf -- "$f"; else rmdir -- "$f" 2>/dev/null || { echo "Directory is not empty" >&2; exit 10; }; fi
else
rm -f -- "$f"
fi`;
// $1 = from, $2 = to. Both parents are contained; the destination must not exist.
const RENAME_SCRIPT = `set -e
from="$1"; to="$2"
fd=$(dirname -- "$from"); td=$(dirname -- "$to")
( cd -- "$fd" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "source escapes volume root" >&2; exit 7; }
( cd -- "$td" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "destination escapes volume root" >&2; exit 7; }
{ [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; }
mv -- "$from" "$to"`;
// $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks.
const PATHKIND_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 2
${ROOT_GUARD}
if [ -d "$f" ] && [ ! -L "$f" ]; then echo directory
elif [ -e "$f" ] || [ -L "$f" ]; then echo file
else echo none
fi`;
export interface VolumeEntry {
name: string;
type: 'file' | 'directory' | 'symlink' | 'other';
@@ -54,10 +131,30 @@ export interface VolumeFileResult {
truncated: boolean;
size: number;
mime: string;
/** mtime in milliseconds (seconds resolution from the helper `stat`, ×1000). */
mtimeMs: number;
}
/** Raw bytes of a volume file for download, bounded to DOWNLOAD_MAX_BYTES. */
export interface VolumeDownload {
buffer: Buffer;
size: number;
filename: string;
}
export type VolumeStat = VolumeEntry;
/**
* Opaque optimistic-concurrency token for an editable volume file. Seconds-
* resolution mtime alone collides for two edits within the same second, so the
* size and a content hash are folded in to keep the guarantee close to the
* millisecond-mtime fs path.
*/
export function makeHelperVersion(mtimeSeconds: number, size: number, content: Buffer): string {
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
return `"v1:${mtimeSeconds}-${size}-${hash}"`;
}
export class PathTraversalError extends Error {
status = 400;
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
@@ -149,6 +246,7 @@ export class VolumeBrowserService {
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
@@ -181,12 +279,12 @@ export class VolumeBrowserService {
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
// Read up to maxBytes+1 to detect truncation precisely. The path is
// passed as $1 (an argv element, never concatenated) and read with
// head -c -- "$1" so a leading-dash filename is never parsed as a flag.
// Read up to maxBytes+1 to detect truncation precisely. The parent is
// contained (cd + pwd -P) and the leaf symlink refused so the read can never
// follow a link out of the volume.
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`head -c ${maxBytes + 1} -- "$1"`,
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${maxBytes + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
@@ -207,9 +305,151 @@ export class VolumeBrowserService {
truncated,
size: meta.size,
mime,
mtimeMs: meta.mtime * 1000,
};
}
/**
* Read a file's full bytes for download, bounded to DOWNLOAD_MAX_BYTES so a
* large file is rejected (413) rather than silently truncated. The parent is
* contained and the leaf symlink refused, like readFile.
*/
async downloadFile(volumeName: string, relPath: string): Promise<VolumeDownload> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot download volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const meta = await this.stat(volumeName, safe);
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
if (meta.size > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${DOWNLOAD_MAX_BYTES + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
throw new ExecError(`Download failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
if (stdout.length > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
return { buffer: stdout, size: stdout.length, filename: path.posix.basename(safe) };
}
/** Probe whether a path is a directory, file, or absent (upload-overwrite check). */
async pathKind(volumeName: string, relPath: string): Promise<'file' | 'directory' | null> {
const safe = sanitizeRelPath(relPath);
if (!safe) return 'directory';
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, ['sh', '-c', PATHKIND_SCRIPT, 'sh', `./${safe}`]);
if (exitCode !== 0) {
// A permission failure on the parent must not be reported as "absent"
// (which would let an exclusive create proceed); surface it as 403. A
// genuinely missing parent (ENOENT) means nothing exists at this path.
if (/Permission denied/i.test(stderr.toString('utf-8'))) throw new ExecError('Permission denied', 403);
return null;
}
const kind = stdout.toString('utf-8').trim();
if (kind === 'directory') return 'directory';
if (kind === 'file') return 'file';
return null;
}
/**
* Write `content` to a volume file. New files are created owned by the helper
* user (65534); existing files are truncated in place so their owner/mode are
* preserved. The write is intentionally NON-ATOMIC (a helper death mid-write
* can leave a truncated file) because the helper cannot chown a renamed temp
* back to the original owner; ownership preservation is the better default for
* editing a service's config. Returns the new mtime/size for the version token.
*/
async writeFile(volumeName: string, relPath: string, content: Buffer): Promise<{ mtimeMs: number; size: number }> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot write the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`],
{ writable: true, stdin: content },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403);
if (exitCode === 9) throw new ExecError('Target is a directory', 400);
throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const meta = await this.stat(volumeName, safe);
return { mtimeMs: meta.mtime * 1000, size: meta.size };
}
/** Create a single directory; its parent must already exist. */
async mkdir(volumeName: string, relPath: string): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Invalid directory path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', MKDIR_SCRIPT, 'sh', `./${safe}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/File exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Create folder failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Delete a file or directory. Removing a symlink unlinks the link, not its target. */
async deletePath(volumeName: string, relPath: string, recursive: boolean): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot delete the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', DELETE_SCRIPT, 'sh', `./${safe}`, recursive ? '1' : '0'],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 10 || /not empty/i.test(msg)) throw new ExecError('Directory is not empty', 409);
throw new ExecError(`Delete failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Rename/move a file or directory within the same volume. */
async rename(volumeName: string, fromRel: string, toRel: string): Promise<void> {
const from = sanitizeRelPath(fromRel);
const to = sanitizeRelPath(toRel);
if (!from || !to) throw new ExecError('Invalid rename path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', RENAME_SCRIPT, 'sh', `./${from}`, `./${to}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Rename failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
// --- internals -----------------------------------------------------------
private async assertVolumeExists(volumeName: string): Promise<void> {
@@ -250,17 +490,23 @@ export class VolumeBrowserService {
}
}
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
private async runHelper(
volumeName: string,
cmd: string[],
opts: { writable?: boolean; stdin?: Buffer } = {},
): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
const docker = DockerController.getInstance(this.nodeId).getDocker();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
const wantStdin = opts.stdin !== undefined;
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
// dockerode's docker.run() with AutoRemove races: Docker can delete the
// container before run()'s internal wait() callback fires, surfacing as
// a 404 "no such container" from docker-modem.
// a 404 "no such container" from docker-modem. Only the target volume mount
// becomes writable for mutations; every other hardening flag is unchanged.
const container = await docker.createContainer({
Image: HELPER_IMAGE,
Cmd: cmd,
@@ -269,6 +515,9 @@ export class VolumeBrowserService {
WorkingDir: VOLUME_MOUNT,
AttachStdout: true,
AttachStderr: true,
AttachStdin: wantStdin,
OpenStdin: wantStdin,
StdinOnce: wantStdin,
HostConfig: {
ReadonlyRootfs: true,
NetworkMode: 'none',
@@ -281,7 +530,7 @@ export class VolumeBrowserService {
Type: 'volume',
Source: volumeName,
Target: VOLUME_MOUNT,
ReadOnly: true,
ReadOnly: !opts.writable,
}],
},
});
@@ -292,13 +541,19 @@ export class VolumeBrowserService {
});
const runPromise = (async () => {
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin });
const streamEnded = new Promise<void>((resolve) => {
stream.once('end', () => resolve());
stream.once('close', () => resolve());
});
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
await container.start();
if (wantStdin && opts.stdin) {
// Feed the file content to the container's stdin, then close it so the
// helper's `cat > file` sees EOF and exits.
stream.write(opts.stdin);
stream.end();
}
const exitInfo = await container.wait();
// Wait for the attach stream to finish flushing demuxed output.
await streamEnded;