diff --git a/backend/src/__tests__/stack-file-roots-service.test.ts b/backend/src/__tests__/stack-file-roots-service.test.ts index b3f951ca..dc5775b7 100644 --- a/backend/src/__tests__/stack-file-roots-service.test.ts +++ b/backend/src/__tests__/stack-file-roots-service.test.ts @@ -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({ diff --git a/backend/src/services/StackFileRootsService.ts b/backend/src/services/StackFileRootsService.ts index 73582aad..db634619 100644 --- a/backend/src/services/StackFileRootsService.ts +++ b/backend/src/services/StackFileRootsService.ts @@ -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 { + 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 { // 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(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];