fix: contain file-explorer writes and browse reachable out-of-base binds (#1465)

The file-explorer editor save resolved a dangling symlink leaf to the link
path and wrote through it with a plain writeFile, which followed the link and
created a file outside the bind/stack root. Reject a resolved leaf that is
itself a symlink (mirroring the managed-stack guard) and promote the save
through the atomic stage-and-rename helper, so the editor save matches its
documented atomicity and can never leave a partial file or land outside the
root.

Bind-root discovery reported every source outside the compose base as
unreachable without probing it, so a config directory mounted into both the
app and the Sencho container was wrongly non-browsable. Probe the declared
source as Sencho actually sees it; dangerous host roots, docker-socket mounts,
and managed-area overlaps stay blocked, and the dangerous classification also
reads the literal declared source so it holds across platforms.
This commit is contained in:
Anso
2026-06-26 09:23:50 -04:00
committed by GitHub
parent 315e8b6379
commit 1c82e3e1d4
4 changed files with 156 additions and 32 deletions
+34 -31
View File
@@ -34,6 +34,24 @@ export interface RootMount {
readOnly: boolean;
}
/** All declarations resolving to one canonical bind source, with its probe result. */
interface BindGroup {
canonical: string;
accessible: boolean;
isDir: boolean;
dockerSock: boolean;
/**
* Set when any raw declared source mapping to this canonical is a dangerous
* host root. Tracked from the declared source (a POSIX container path that
* stays literal across platforms), not the canonical, so that buildBindRoot's
* combined dangerous check (canonical OR this) still blocks a bind like /etc
* even where realpath rewrites the source (a non-existent POSIX path resolves
* drive-prefixed on a Windows dev box).
*/
dangerousSource: boolean;
mounts: RootMount[];
}
export interface StackFileRoot {
/** Opaque, server-minted id. The resolved path/name lives in metadata, never in the id. */
id: string;
@@ -117,27 +135,22 @@ export function isDangerousHostPath(p: string): boolean {
}
/**
* Browse-accessibility probe for a bind root, scoped to the compose base dir.
* The realpath/stat run ONLY for a source that lexically resolves inside
* `baseDir`: in the containerized deployment that is the only host area the
* Sencho process can reach, so a source outside it is unreachable anyway and is
* reported non-accessible without touching the filesystem. The containment is
* an INLINE path.resolve + startsWith guard at each filesystem sink (the
* realpath of a within-base symlink can still escape, so the resolved canonical
* is re-checked before stat). Returns the canonical realpath so the route can
* pass it to FileSystemService as the containment root.
* Browse-accessibility probe for a declared bind source. realpath + stat the
* source as Sencho actually sees it: a path Sencho cannot reach (not mounted
* into the container, or absent on the host) surfaces ENOENT and degrades to
* non-accessible. Bind sources are declared by the stack's own compose file and
* may legitimately live outside the compose base (a config directory mounted
* into both the app and the Sencho container), so accessibility is decided by
* whether the resolved path is stat-able, not by where it sits. Safety against
* privileged or managed paths is the caller's job: buildBindRoot still blocks
* dangerous host roots, docker-socket mounts, and overlaps with Sencho's managed
* areas. Returns the canonical realpath the route uses as the containment root
* for file operations on this bind.
*/
export async function probeBindRootAccess(
absPath: string,
baseDir: string,
): Promise<{ canonical: string; accessible: boolean; isDir: boolean }> {
const base = path.resolve(baseDir);
const resolved = path.resolve(absPath);
// Out-of-base sources keep their original path for the dangerous/overlap
// classification the caller does, but are never statted.
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
return { canonical: absPath, accessible: false, isDir: false };
}
// A missing path (ENOENT) is the common, expected "not reachable" outcome and
// is left silent; any other code (e.g. EACCES on a path that exists but Sencho
// cannot read) is logged so an operator chasing "why can't I browse this bind"
@@ -155,11 +168,6 @@ export async function probeBindRootAccess(
logNonEnoent('realpath', err);
return { canonical: resolved, accessible: false, isDir: false };
}
// A within-base source can be a symlink whose target escapes the base; re-check
// the resolved canonical inline before the stat sink.
if (canonical !== base && !canonical.startsWith(base + path.sep)) {
return { canonical, accessible: false, isDir: false };
}
try {
const st = await fsPromises.stat(canonical);
return { canonical, accessible: true, isDir: st.isDirectory() };
@@ -299,13 +307,6 @@ export class StackFileRootsService {
baseDir: string,
stackDir: string,
): Promise<StackFileRoot[]> {
interface BindGroup {
canonical: string;
accessible: boolean;
isDir: boolean;
dockerSock: boolean;
mounts: RootMount[];
}
const probeByRaw = new Map<string, { canonical: string; accessible: boolean; isDir: boolean }>();
const bindByCanonical = new Map<string, BindGroup>();
const volByName = new Map<string, { name: string; mounts: RootMount[] }>();
@@ -317,7 +318,7 @@ export class StackFileRootsService {
const rawAbs = path.isAbsolute(m.source) ? m.source : path.resolve(stackDir, m.source);
let probe = probeByRaw.get(rawAbs);
if (!probe) {
probe = await probeBindRootAccess(rawAbs, baseDir);
probe = await probeBindRootAccess(rawAbs);
probeByRaw.set(rawAbs, probe);
}
let group = bindByCanonical.get(probe.canonical);
@@ -327,11 +328,13 @@ export class StackFileRootsService {
accessible: probe.accessible,
isDir: probe.isDir,
dockerSock: false,
dangerousSource: false,
mounts: [],
};
bindByCanonical.set(probe.canonical, group);
}
group.mounts.push(mount);
if (isDangerousHostPath(rawAbs)) group.dangerousSource = true;
if (isDockerSocketMount({ source: m.source, target: m.target })) group.dockerSock = true;
} else if (m.type === 'named' && m.source) {
const resolvedName = model.volumes[m.source]?.name ?? m.source;
@@ -358,7 +361,7 @@ export class StackFileRootsService {
}
private buildBindRoot(
group: { canonical: string; accessible: boolean; isDir: boolean; dockerSock: boolean; mounts: RootMount[] },
group: BindGroup,
baseDir: string,
stackDir: string,
): StackFileRoot | null {
@@ -377,7 +380,7 @@ export class StackFileRootsService {
const dataDir = resolveDataDir();
const overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir));
const dangerous = isDangerousHostPath(canonical) || group.dockerSock;
const dangerous = isDangerousHostPath(canonical) || group.dangerousSource || group.dockerSock;
const readonly = group.mounts.every((m) => m.readOnly);
const isFile = group.accessible && !group.isDir;