mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user