fix: block file-explorer binds that overlap Sencho's application directory (#1479)

The Files & Volumes explorer derives its browsable roots from a stack's declared
bind mounts. A bind whose source resolves inside Sencho's own application root
(the working directory the image runs from, holding the compiled dist/, the
served public/, and node_modules) was classified accessible, browsable, and
writable. A non-admin with stack edit rights could therefore declare a bind such
as /app/dist into a stack and gain read/write access to Sencho's program files.

The bind-root classifier now treats the application root as a managed area, the
same way it already treats the compose base and the data directory, so a bind
that overlaps it is non-browsable and non-writable and the file routes reject
read and write operations against it. The check is gated on the bind not being
inside the current stack directory, so a legitimate stack-scoped bind under the
compose base (which can sit under the application root) stays browsable.

The application root is resolved dynamically from the process working directory,
mirroring how the data directory is resolved, rather than hardcoding a path.
This commit is contained in:
Anso
2026-06-26 21:11:37 -04:00
committed by GitHub
parent 2911ccfe2b
commit 3e2f0450c2
3 changed files with 41 additions and 5 deletions
+3
View File
@@ -67,3 +67,6 @@ website/
# Internal engineering docs (not published)
docs/internal/
# Transient temp dir a file-roots test creates under the backend working dir
backend/sfr-app-*/
@@ -193,6 +193,23 @@ describe('StackFileRootsService.listRoots', () => {
expect(bind?.browsable).toBe(false);
});
it("suppresses a bind that overlaps Sencho's own application directory", async () => {
// process.cwd() is Sencho's install root in the container (/app, holding
// dist/, public/, node_modules). A non-admin must not be able to declare a
// bind into it and reach Sencho's program files via the file explorer.
const appDir = await fs.realpath(await fs.mkdtemp(path.join(process.cwd(), 'sfr-app-')));
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: appDir, target: '/config', read_only: false }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
const bind = roots.find((r) => r.kind === 'bind');
expect(bind?.managedSourceOverlap).toBe(true);
expect(bind?.browsable).toBe(false);
expect(bind?.writable).toBe(false);
} finally {
await fs.rm(appDir, { recursive: true, force: true }).catch(() => {});
}
});
it('resolves a named volume by its Docker name (not the compose key) and inspects that name', async () => {
const inspected: string[] = [];
stub({
+21 -5
View File
@@ -127,6 +127,20 @@ function resolveDataDir(): string {
return path.resolve(process.env.DATA_DIR || path.join(process.cwd(), 'data'));
}
/**
* Sencho's own application/install root: the working directory the image runs
* from (`/app`, holding the compiled `dist/`, the served `public/`, and
* `node_modules`). A bind that overlaps it would let the file explorer read or
* overwrite Sencho's program files, so it is treated as a managed area and never
* browsable. Under the Docker defaults the compose base and data dir sit under
* it: the compose base stays reachable through the per-stack stack-source root,
* and the data dir has its own overlap suppression, so this check interferes
* with neither.
*/
function resolveAppRoot(): string {
return path.resolve(process.cwd());
}
/** A bind source equal to or under one of the dangerous roots (POSIX semantics). */
export function isDangerousHostPath(p: string): boolean {
const norm = p.replace(/\\/g, '/');
@@ -374,19 +388,21 @@ export class StackFileRootsService {
const inStack = isPathWithinBase(canonical, stackDir); // strictly within (equal handled above)
// A bind that overlaps Sencho's own managed areas (the compose base dir, a
// sibling stack, or the data dir that holds sencho.db / encryption.key) must
// never become a browsable/editable root. Compare in both directions so a
// mount equal to, inside, or an ancestor of a managed dir is caught.
// sibling stack, the data dir that holds sencho.db / encryption.key, or the
// application root that holds Sencho's program files) must never become a
// browsable/editable root. Compare in both directions so a mount equal to,
// inside, or an ancestor of a managed dir is caught.
const dataDir = resolveDataDir();
const appRoot = resolveAppRoot();
const overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir));
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir) || overlapsManaged(appRoot));
const dangerous = isDangerousHostPath(canonical) || group.dangerousSource || group.dockerSock;
const readonly = group.mounts.every((m) => m.readOnly);
const isFile = group.accessible && !group.isDir;
let warning: string | null = null;
if (overlap) {
warning = "This mount overlaps Sencho's managed stack area. Browse the owning stack's source instead.";
warning = "This mount overlaps a Sencho-managed area (stack storage, data, or application files) and cannot be browsed.";
} else if (dangerous) {
warning = 'This mount targets a protected host path and cannot be browsed.';
} else if (!group.accessible) {