fix: contain file-explorer binds that reach dangling symlinked managed paths (#1490)

A previous fix canonicalized each Sencho-managed path (compose base, data dir,
application root, OS temp root, upload spool, Trivy binary, Trivy cache) with
fs.realpath so a managed path that is itself a symlink could not be reached through
a bind to its real target. fs.realpath returns ENOENT for a dangling symlink whose
final leaf does not exist yet, so that case fell through to the configured path only
and the implied target was lost. With TRIVY_BIN pointing at a symlink to a not yet
created binary, a bind to the symlink target's existing parent directory was still
classified as browsable and writable. A stack editor could create the leaf there
and have a later pre or post deploy scan execute the attacker supplied binary, which
the default image runs as root. The same gap applied to dangling symlinked upload
spool, Trivy cache, and OS temp roots.

Resolve managed paths with a helper that follows the symlink chain even when the
final leaf is absent: it follows a dangling link through readlink (resolving a
relative target against the link's own directory) and, for a missing leaf, resolves
the longest existing ancestor and re-appends the absent suffix, so the real target
location is always represented in the overlap set. A bounded hop count guards
against a symlink cycle that never surfaces as ELOOP, falling back to the lexical
path with a log. An unexpected resolution failure (EACCES, ELOOP) keeps the
configured path as the containment anchor, continues discovery for the other
managed paths, and is logged.

Tests: a mock-based regression test that fails before the fix, a real-filesystem
dangling-symlink test on Linux and macOS CI, a relative-symlink-target case, and a
non-ENOENT failure case asserting discovery degrades safely.
This commit is contained in:
Anso
2026-06-27 14:32:50 -04:00
committed by GitHub
parent 349ee1f905
commit bee0dfd094
2 changed files with 199 additions and 12 deletions
@@ -21,6 +21,11 @@ import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
// Real symlink creation needs admin or Developer Mode on Windows, so the
// real-filesystem dangling-symlink case runs on Linux/macOS (CI) and skips on the
// Windows dev box, mirroring filesystem-symlink-escape.test.ts.
const isWindows = process.platform === 'win32';
const STACK = 'app';
let baseDir: string;
let stackDir: string;
@@ -357,6 +362,96 @@ describe('StackFileRootsService.listRoots', () => {
}
});
it('suppresses a bind to the target parent of a dangling symlinked managed Trivy binary (missing leaf)', async () => {
// The exploitable edge: TRIVY_BIN=/opt/trivy -> /srv/tool/trivy where /srv/tool
// exists but the leaf /srv/tool/trivy does not exist yet. fs.realpath on the
// dangling symlink throws ENOENT, so canonicalization must follow the link
// chain manually and preserve the resolved target parent plus the missing leaf.
// Otherwise a bind to /srv/tool is allowed, the stack editor creates trivy
// there, and a later scan executes the attacker-supplied binary.
const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-dangling-target-')));
const danglingLeaf = path.join(targetDir, 'trivy'); // intentionally never created
const symlinkBin = path.join(REAL_TMP, 'sfr-dangling-trivy-bin'); // dangling symlink -> danglingLeaf
process.env.TRIVY_BIN = symlinkBin;
const enoent = (): never => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q);
// Simulate the OS view of a dangling symlink: realpath of the link (and of its
// missing target) is ENOENT, lstat reports the link as a symlink, readlink
// yields the absolute target. Every existing path resolves to itself.
vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) || isAt(p, danglingLeaf)) ? enoent() : p) as typeof fs.realpath);
vi.spyOn(fs, 'lstat').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) ? { isSymbolicLink: () => true } : enoent())) as unknown as typeof fs.lstat);
vi.spyOn(fs, 'readlink').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) ? danglingLeaf : enoent())) as typeof fs.readlink);
try {
// Bind the existing target directory, whose missing leaf the symlink points at.
stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, 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?.browsable).toBe(false);
expect(bind?.writable).toBe(false);
} finally {
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
}
});
it('follows a relative dangling managed symlink target when canonicalizing (relative readlink)', async () => {
// Guards the relative-target branch of realpathAllowingMissing: a managed
// symlink whose readlink returns a relative path must be resolved against the
// link's own directory, not the cwd. Package managers commonly create such links.
const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-reltarget-')));
const danglingLeaf = path.join(targetDir, 'trivy'); // never created
const linkDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rellink-')));
const symlinkBin = path.join(linkDir, 'trivy');
const relativeLink = path.relative(linkDir, danglingLeaf); // resolves to danglingLeaf against linkDir
process.env.TRIVY_BIN = symlinkBin;
const enoent = (): never => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q);
vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) || isAt(p, danglingLeaf)) ? enoent() : p) as typeof fs.realpath);
vi.spyOn(fs, 'lstat').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) ? { isSymbolicLink: () => true } : enoent())) as unknown as typeof fs.lstat);
vi.spyOn(fs, 'readlink').mockImplementation((async (p: string) =>
(isAt(p, symlinkBin) ? relativeLink : enoent())) as typeof fs.readlink);
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, 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?.browsable).toBe(false);
} finally {
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(linkDir, { recursive: true, force: true }).catch(() => {});
}
});
it.skipIf(isWindows)('suppresses a bind to the real target parent of a dangling managed symlink (real fs)', async () => {
// Authoritative real-symlink form of the dangling-leaf case: create an actual
// dangling symlink whose leaf is never created, and confirm a bind to the
// existing target directory is suppressed. This exercises real fs.realpath /
// lstat / readlink rather than a mock, so it catches any divergence between
// the canonicalization helper and real OS symlink semantics.
const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rdangling-target-')));
const danglingLeaf = path.join(targetDir, 'trivy'); // never created
const linkDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rdangling-link-')));
const symlinkBin = path.join(linkDir, 'trivy');
await fs.symlink(danglingLeaf, symlinkBin); // dangling: the target leaf is absent
process.env.TRIVY_BIN = symlinkBin;
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, 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?.browsable).toBe(false);
expect(bind?.writable).toBe(false);
} finally {
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(linkDir, { recursive: true, force: true }).catch(() => {});
}
});
it('keeps a legitimate external bind browsable when a configured managed path is absent (ENOENT realpath tolerated)', async () => {
// A relocated TRIVY_BIN (or upload spool) routinely points at a path that does
// not exist yet on a fresh install, so realpath on a configured managed path
@@ -377,6 +472,41 @@ describe('StackFileRootsService.listRoots', () => {
}
});
it('degrades safely when a managed path cannot be canonicalized (non-ENOENT error keeps containment)', async () => {
// A non-ENOENT realpath failure on one managed path (e.g. EACCES on an
// intermediate dir) must not collapse discovery or silently drop containment:
// the loop continues for other paths, the configured path stays anchored, and
// the failure is logged.
const binDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-eacces-bin-')));
const trivyBin = path.join(binDir, 'trivy');
process.env.TRIVY_BIN = trivyBin;
const outside = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-eacces-legit-')));
const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q);
vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => {
if (isAt(p, trivyBin)) throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
return p;
}) as typeof fs.realpath);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
stub({ rendered: renderModel({
legit: [{ type: 'bind', source: outside, target: '/config', read_only: false }],
tool: [{ type: 'bind', source: binDir, target: '/opt/trivy', read_only: false }],
}) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
const legit = roots.find((r) => r.hostPathOrName === outside);
const tool = roots.find((r) => r.hostPathOrName === binDir);
// Discovery did not collapse: the unrelated bind is still browsable.
expect(legit?.browsable).toBe(true);
// The configured TRIVY_BIN literal still anchors containment for its own dir.
expect(tool?.managedSourceOverlap).toBe(true);
expect(tool?.browsable).toBe(false);
expect(warnSpy).toHaveBeenCalled();
} finally {
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(outside, { 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({
+69 -12
View File
@@ -105,6 +105,10 @@ export function stackSourceFileRoot(hostPathOrName = ''): StackFileRoot {
const ROOTS_CACHE_TTL_MS = 15_000;
// Upper bound on symlink hops when canonicalizing a managed path, guarding against
// a cycle of dangling symlinks that never surfaces as ELOOP from the OS.
const MAX_SYMLINK_HOPS = 40;
// Dangerous host directories: a bind equal to or under any of these grants
// node-level access and is never browsable. Two groups:
// - kernel/OS state: /etc, /proc, /sys, /dev, /var/run, /run.
@@ -153,6 +157,56 @@ function resolveAppRoot(): string {
return path.resolve(process.cwd());
}
/**
* Canonicalize an absolute path, following symlinks but tolerating components
* (including the final leaf) that do not exist yet. fs.realpath fails with ENOENT
* for a dangling symlink, which would drop the symlink target from the managed
* overlap set and let a bind to the target's existing parent slip through; a stack
* editor could then create the missing leaf and have a later scan execute it. This
* walks the chain manually: it follows a dangling symlink via readlink, and for a
* missing leaf it resolves the longest existing ancestor and re-appends the absent
* suffix, so the real target location is always represented. Non-ENOENT errors
* (ELOOP, EACCES) propagate to the caller, which keeps the configured path as the
* containment anchor. A symlink cycle that never surfaces as ELOOP is bounded by
* an internal hop limit and falls back to the lexical path.
*/
async function realpathAllowingMissing(target: string, hops = 0): Promise<string> {
const resolved = path.resolve(target);
// Bound the recursion so a symlink cycle that does not surface as ELOOP cannot
// spin forever; fall back to the lexical path and log so the degraded (less
// resolved) result is traceable, matching the rest of this file's convention.
if (hops > MAX_SYMLINK_HOPS) {
console.warn('[StackFileRoots] managed-root canonicalize hop limit reached, using lexical path:', sanitizeForLog(resolved));
return resolved;
}
try {
return await fsPromises.realpath(resolved);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
// A dangling symlink at this path: follow its target (resolving a relative
// target against the link's own directory).
try {
const st = await fsPromises.lstat(resolved);
if (st.isSymbolicLink()) {
const link = await fsPromises.readlink(resolved);
const next = path.isAbsolute(link) ? link : path.resolve(path.dirname(resolved), link);
return await realpathAllowingMissing(next, hops + 1);
}
} catch (err) {
// realpath ENOENT was ambiguous (dangling symlink vs. absent leaf); an lstat
// ENOENT narrows it to "no entry here at all", so fall through to the parent
// walk. Any other error (EACCES, ELOOP) is a real failure the caller handles.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
// The leaf does not exist (and is not a symlink). Canonicalize the parent and
// re-append the leaf so a missing file under a real, possibly symlinked, dir
// still resolves to its true location.
const parent = path.dirname(resolved);
if (parent === resolved) return resolved; // reached the filesystem root
return path.join(await realpathAllowingMissing(parent, hops + 1), path.basename(resolved));
}
/**
* 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,
@@ -181,22 +235,25 @@ async function resolveManagedRoots(baseDir: string): Promise<string[]> {
// 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).
// path (catches a bind to its literal parent) and the canonical target (catches
// a bind to the symlink target's parent). realpathAllowingMissing follows the
// link even when its leaf does not exist yet, so a dangling managed symlink
// cannot be created-then-executed through an allowed bind.
const roots = new Set<string>(configured);
for (const p of configured) {
try {
roots.add(await fsPromises.realpath(p));
roots.add(await realpathAllowingMissing(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));
}
// The helper tolerates missing components, so a failure here is an
// unexpected resolution error (e.g. EACCES on an intermediate dir, ELOOP).
// The configured path stays in the set as the containment anchor, but the
// symlink's resolved-target subtree is not anchored on this branch:
// containment then falls back to the literal configured path plus the bind
// accessibility probe, which realpaths the declared source under the same
// user and degrades an unreachable bind to non-browsable. Log a trail for an
// operator chasing a containment surprise.
console.warn('[StackFileRoots] managed-root canonicalize failed (%s):',
(err as NodeJS.ErrnoException).code ?? 'unknown', sanitizeForLog(p));
}
}
return [...roots];