fix(stacks): harden stack file path containment against symlink escapes (#1415)

* fix(stacks): harden stack file path containment against symlink escapes

The legacy managed-stack methods enforced path containment lexically
(path.resolve + startsWith), which does not follow symlinks. A stack
directory under the compose root that is itself a symlink or junction
could let a managed-file operation (write compose.yaml/.env, delete a
stack, backup, restore, snapshot) follow the link and read, write, or
delete a file of the same name outside the compose root.

Add a realpath-based containment guard that walks up to the deepest
existing path component, confirms its canonical location is inside the
canonical compose root, and rejects both an out-of-tree resolution and a
dangling symlink (which a write or mkdir would still follow). The guard
runs at every legacy managed-stack sink, alongside the existing lexical
barriers. A legitimately symlinked compose root is not a false positive
because both sides are canonicalized through the same link, and the
guard is a no-op for not-yet-created targets so stack creation and the
flat-to-directory migration are unaffected.

* fix(stacks): satisfy the path-injection sanitizer in the symlink containment guard

assertRealWithinBase resolves a user-derived path and probes it with
realpath/lstat to run the containment check, which static analysis flags
as path injection because the probes lacked the inline barrier its
sanitizer recognizes. Add the canonical path.resolve + startsWith barrier
at the top of the helper, the same form every other sink in this file
uses, and seed the realpath walk from the sanitized value.

Behavior is unchanged: callers always pass an absolute, already-contained
path, so the barrier is a no-op pre-check for them, and the realpath walk
still catches the symlink and dangling-link escapes.
This commit is contained in:
Anso
2026-06-22 19:53:59 -04:00
committed by GitHub
parent f91227dada
commit 69ba0e6d21
3 changed files with 439 additions and 0 deletions
+6
View File
@@ -27,6 +27,12 @@ vi.mock('fs', () => ({
rename: vi.fn(),
copyFile: vi.fn(),
unlink: vi.fn(),
// deleteStack now realpath-checks the stack dir against the compose root
// before rm. Resolve to the absolute path (no symlink) so the containment
// guard passes and these tests still exercise the rm error translation;
// the guard's symlink-escape behaviour is covered in
// filesystem-symlink-escape.test.ts.
realpath: vi.fn((p: string) => Promise.resolve(path.resolve(p))),
},
}));