mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
fix: contain file-explorer binds that reach symlinked Sencho-managed paths (#1489)
The file-explorer overlap check canonicalized declared bind sources (it realpaths the source) but compared them against the Sencho-managed paths (compose base, data dir, application root, OS temp root, upload spool, Trivy binary, Trivy cache) using path.resolve only. A managed path that is itself a symlink, for example a relocated Trivy binary whose configured path links to a real binary elsewhere, was therefore compared by its symlink path. A bind to the symlink target's real directory did not register as a managed overlap and became browsable and writable, so a stack editor could overwrite the real binary a later pre or post deploy scan executes, or reach transient registry credentials under a symlinked temporary root. Resolve each managed path to its canonical target and keep both the configured path and the realpath target in the overlap set, mirroring the bind-side canonicalization. Containment only ever expands, so existing deployments and fresh installs are unaffected. A missing managed path (the common fresh-install case) is tolerated: the configured path still anchors containment, and any non-ENOENT realpath failure is logged rather than collapsing discovery. Adds two tests: a regression test that fails when a symlinked managed Trivy path is compared without canonicalization, and a fresh-install test that an absent managed path leaves a legitimate external bind browsable.
This commit is contained in:
@@ -167,13 +167,39 @@ function resolveAppRoot(): string {
|
||||
* 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)); };
|
||||
async function resolveManagedRoots(baseDir: string): Promise<string[]> {
|
||||
const configured = [path.resolve(baseDir), resolveDataDir(), resolveAppRoot(), path.resolve(os.tmpdir())];
|
||||
const add = (value: string | undefined): void => { if (value) configured.push(path.resolve(value)); };
|
||||
add(process.env.SENCHO_UPLOAD_DIR);
|
||||
add(process.env.TRIVY_BIN);
|
||||
add(process.env.TRIVY_CACHE_DIR);
|
||||
return roots;
|
||||
|
||||
// Bind sources are canonicalized before the overlap check (probeBindRootAccess
|
||||
// realpaths the declared source), so a managed path that is itself a symlink
|
||||
// must be canonicalized too. Otherwise TRIVY_BIN=/opt/trivy -> /srv/tool/trivy
|
||||
// is only compared as /opt/trivy: a bind to /srv/tool slips past the overlap
|
||||
// check, letting a stack editor overwrite the real Trivy binary a later scan
|
||||
// executes (the same gap re-exposes a symlinked upload spool, Trivy cache, or
|
||||
// OS temp root holding transient registry credentials). Keep both the configured
|
||||
// path (catches a bind to its literal parent) and the realpath target (catches
|
||||
// a bind to the symlink target's parent).
|
||||
const roots = new Set<string>(configured);
|
||||
for (const p of configured) {
|
||||
try {
|
||||
roots.add(await fsPromises.realpath(p));
|
||||
} catch (err) {
|
||||
// ENOENT is expected: a configured-but-absent path (e.g. SENCHO_UPLOAD_DIR,
|
||||
// or a relocated TRIVY_BIN/TRIVY_CACHE_DIR that env points at but that does
|
||||
// not exist yet) has no canonical target, and the configured path already
|
||||
// anchors containment. Anything else (e.g. EACCES) is logged so an operator
|
||||
// chasing a containment surprise has a trail.
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn('[StackFileRoots] managed-root realpath failed (%s):', code ?? 'unknown', sanitizeForLog(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
/** A bind source equal to or under one of the dangerous roots (POSIX semantics). */
|
||||
@@ -398,9 +424,10 @@ export class StackFileRootsService {
|
||||
}
|
||||
}
|
||||
|
||||
const managedRoots = await resolveManagedRoots(baseDir);
|
||||
const roots: StackFileRoot[] = [];
|
||||
for (const group of bindByCanonical.values()) {
|
||||
const root = this.buildBindRoot(group, baseDir, stackDir);
|
||||
const root = this.buildBindRoot(group, managedRoots, stackDir);
|
||||
if (root) roots.push(root);
|
||||
}
|
||||
for (const group of volByName.values()) {
|
||||
@@ -411,7 +438,7 @@ export class StackFileRootsService {
|
||||
|
||||
private buildBindRoot(
|
||||
group: BindGroup,
|
||||
baseDir: string,
|
||||
managedRoots: string[],
|
||||
stackDir: string,
|
||||
): StackFileRoot | null {
|
||||
const { canonical } = group;
|
||||
@@ -429,7 +456,7 @@ export class StackFileRootsService {
|
||||
// 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 && resolveManagedRoots(baseDir).some(overlapsManaged);
|
||||
const overlap = !inStack && managedRoots.some(overlapsManaged);
|
||||
const dangerous = isDangerousHostPath(canonical) || group.dangerousSource || group.dockerSock;
|
||||
const readonly = group.mounts.every((m) => m.readOnly);
|
||||
const isFile = group.accessible && !group.isDir;
|
||||
|
||||
Reference in New Issue
Block a user