fix: contain file-explorer binds into Sencho's temporary and tool directories (#1487)

The file-explorer root containment treated the OS temp root as an ordinary
host path, so a stack author with stack:edit could declare it (for example
/tmp) as a bind source and browse it. Sencho writes short-lived secrets there:
ComposeService and TrivyService stage a docker config.json holding resolved
registry credentials, uploads spool under it, and compose/git/scan runs create
working dirs there. Exposing that directory is a credible path to read
admin-configured registry credentials during a pull or deploy.

The same gap left env-relocatable tool paths outside containment: a Trivy
binary placed at a custom TRIVY_BIN (for example under /opt, which is otherwise
allowed) could be overwritten through a bind and then executed by a privileged
pre-deploy scan.

Treat the OS temp root and the configurable upload spool, Trivy binary, and
Trivy cache as Sencho-managed areas, so a bind overlapping any of them (in
either direction) is never browsable, writable, or chmodable. The managed Trivy
install and cache already sit under the data dir and stay covered. Legitimate
external binds outside these areas remain fully editable.
This commit is contained in:
Anso
2026-06-26 23:26:46 -04:00
committed by GitHub
parent c2b508e8b1
commit 3386c63152
2 changed files with 131 additions and 11 deletions
@@ -24,6 +24,17 @@ import { FileSystemService } from '../services/FileSystemService';
const STACK = 'app';
let baseDir: string;
let stackDir: string;
// The real OS temp, captured before beforeEach redirects os.tmpdir() for the
// service. Test scratch (baseDir, legitimate external binds) lives here and stays
// browsable, while the service's view of the OS temp root is pointed at a separate
// dir so the managed-temp containment can be exercised without flagging the
// test's own scratch.
const REAL_TMP = os.tmpdir();
let managedTmpDir: string;
// Env keys the managed-temp tests mutate. Snapshotted and restored around every
// case so an inherited value (CI or a developer shell) is never clobbered.
const MANAGED_ENV_KEYS = ['TMPDIR', 'TEMP', 'TMP', 'SENCHO_UPLOAD_DIR', 'TRIVY_BIN', 'TRIVY_CACHE_DIR'] as const;
let savedEnv: Partial<Record<(typeof MANAGED_ENV_KEYS)[number], string | undefined>>;
interface RawMount { type: 'bind' | 'volume' | 'tmpfs'; source?: string; target: string; read_only?: boolean }
@@ -53,14 +64,28 @@ function stub(opts: { rendered: string | null; volumeInspect?: (name: string) =>
}
beforeEach(async () => {
baseDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'sfr-base-')));
baseDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-base-')));
stackDir = path.join(baseDir, STACK);
await fs.mkdir(stackDir, { recursive: true });
// Redirect os.tmpdir() (the service's "OS temp root") to a dedicated dir, kept
// separate from REAL_TMP where the test scratch lives, so a bind to the OS temp
// root can be asserted non-browsable while ordinary REAL_TMP binds stay browsable.
managedTmpDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-managed-tmp-')));
savedEnv = {};
for (const key of MANAGED_ENV_KEYS) savedEnv[key] = process.env[key];
process.env.TMPDIR = managedTmpDir;
process.env.TEMP = managedTmpDir;
process.env.TMP = managedTmpDir;
});
afterEach(async () => {
vi.restoreAllMocks();
for (const key of MANAGED_ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
await fs.rm(baseDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(managedTmpDir, { recursive: true, force: true }).catch(() => {});
// The service cache is module-level; clear it between cases.
StackFileRootsService.invalidate(1, STACK);
});
@@ -127,7 +152,7 @@ describe('StackFileRootsService.listRoots', () => {
// A config directory mounted into both the app and the Sencho container can
// legitimately live outside the compose base. When Sencho can stat it, the
// root must be fully browsable/editable, not silently dropped as unreachable.
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'sfr-ext-')));
const outside = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-ext-')));
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: outside, target: '/config', read_only: false }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
@@ -230,6 +255,78 @@ describe('StackFileRootsService.listRoots', () => {
}
});
it("suppresses a bind to the OS temp root, where Sencho writes transient registry credentials", async () => {
// ComposeService and TrivyService write a docker config.json (resolved
// registry auth) under os.tmpdir(); a bind that exposes that dir would let
// the file explorer read those secrets. (os.tmpdir() is managedTmpDir here.)
stub({ rendered: renderModel({ web: [{ type: 'bind', source: managedTmpDir, target: '/host-tmp', 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);
expect(bind?.chmodable).toBe(false);
});
it('suppresses a bind into a subdirectory of the OS temp root (the per-scan credential dir)', async () => {
const sub = path.join(managedTmpDir, 'sencho-trivy-xyz');
await fs.mkdir(sub);
stub({ rendered: renderModel({ web: [{ type: 'bind', source: sub, target: '/c' }] }) });
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);
});
it('suppresses a bind to a relocated upload spool (SENCHO_UPLOAD_DIR outside the OS temp root)', async () => {
const uploadDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-upload-')));
process.env.SENCHO_UPLOAD_DIR = uploadDir;
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: uploadDir, target: '/u', 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 {
// env is restored by afterEach; only the scratch dir needs removing here.
await fs.rm(uploadDir, { recursive: true, force: true }).catch(() => {});
}
});
it('suppresses a bind that exposes a relocated Trivy binary (TRIVY_BIN), so it cannot be overwritten then run', async () => {
const binDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-trivybin-')));
process.env.TRIVY_BIN = path.join(binDir, 'trivy');
try {
// A bind to the directory that holds the configured Trivy binary is an
// ancestor of that binary, so it must be suppressed.
stub({ rendered: renderModel({ web: [{ type: 'bind', source: binDir, target: '/opt/trivy', 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?.writable).toBe(false);
} finally {
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {});
}
});
it('suppresses a bind that is an ancestor of a relocated Trivy cache (TRIVY_CACHE_DIR)', async () => {
// The cache lives in a subdirectory; binding its parent is the reverse
// overlap direction (the managed dir is within the bind), which must also
// be caught so the cache cannot be reached through the parent bind.
const parent = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-cacheparent-')));
process.env.TRIVY_CACHE_DIR = path.join(parent, 'trivy-cache');
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: parent, target: '/cache', 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);
} finally {
await fs.rm(parent, { 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({
+32 -9
View File
@@ -14,6 +14,7 @@
* the stack dir).
*/
import path from 'path';
import os from 'os';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
@@ -152,6 +153,29 @@ function resolveAppRoot(): string {
return path.resolve(process.cwd());
}
/**
* Every directory Sencho manages at runtime. A bind overlapping any of these (in
* either direction) must never become a browsable root. Besides the compose base,
* the data dir, and the application root, this covers:
* - the OS temp root: Sencho writes short-lived files there that include
* resolved registry credentials (the docker config.json ComposeService and
* TrivyService hand to docker/trivy), upload spools, and compose/git/scan
* working dirs. A bind to it would let the file explorer read those secrets.
* - the configurable upload spool, Trivy binary, and Trivy cache, which env
* vars can relocate outside the data dir. The Trivy binary is included so it
* cannot be overwritten through a bind and then run by a pre-deploy scan.
* The managed Trivy install and cache default under the data dir, so they are
* already covered unless an env override moves them elsewhere.
*/
function resolveManagedRoots(baseDir: string): string[] {
const roots = [path.resolve(baseDir), resolveDataDir(), resolveAppRoot(), path.resolve(os.tmpdir())];
const add = (value: string | undefined): void => { if (value) roots.push(path.resolve(value)); };
add(process.env.SENCHO_UPLOAD_DIR);
add(process.env.TRIVY_BIN);
add(process.env.TRIVY_CACHE_DIR);
return roots;
}
/** 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, '/');
@@ -398,22 +422,21 @@ export class StackFileRootsService {
if (canonical === stackDir) return null;
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, 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();
// A bind that overlaps a Sencho-managed area (the compose base, a sibling
// stack, the data dir holding sencho.db / encryption.key, the application
// root holding Sencho's program files, or the OS temp root and configurable
// tool paths holding transient registry credentials and the Trivy binary)
// 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 overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir) || overlapsManaged(appRoot));
const overlap = !inStack && resolveManagedRoots(baseDir).some(overlapsManaged);
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 a Sencho-managed area (stack storage, data, or application files) and cannot be browsed.";
warning = "This mount overlaps a Sencho-managed area (stack storage, data, application, or temporary credential files) and cannot be browsed.";
} else if (dangerous) {
warning = 'This mount targets a protected host path and cannot be browsed.';
} else if (!group.accessible) {