diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 6a06b53..c631206 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -103,6 +103,45 @@ func TestSymlinkEscapeBlocked(t *testing.T) { } } +func TestSafeJoinAllowsSymlinkedAreaRoot(t *testing.T) { + dir := t.TempDir() + realRoot := filepath.Join(dir, "real-root") + if err := os.Mkdir(realRoot, 0o755); err != nil { + t.Fatal(err) + } + aliasRoot := filepath.Join(dir, "alias-root") + if err := os.Symlink(realRoot, aliasRoot); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + joined, err := safeJoin(aliasRoot, "notes.txt") + if err != nil { + t.Fatalf("safeJoin should allow paths under a symlinked/canonicalized root: %v", err) + } + if !within(filepath.Clean(aliasRoot), joined) { + t.Fatalf("safeJoin returned %q, want under lexical root %q", joined, aliasRoot) + } +} + +func TestSafeJoinRejectsEscapeThroughSymlinkedAreaRoot(t *testing.T) { + dir := t.TempDir() + realRoot := filepath.Join(dir, "real-root") + if err := os.Mkdir(realRoot, 0o755); err != nil { + t.Fatal(err) + } + aliasRoot := filepath.Join(dir, "alias-root") + if err := os.Symlink(realRoot, aliasRoot); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := os.Symlink("/etc", filepath.Join(realRoot, "escape")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + if _, err := safeJoin(aliasRoot, "escape/passwd"); err == nil { + t.Fatal("safeJoin should reject paths that escape through a symlink below the area root") + } +} + func TestOwnPublicWritable(t *testing.T) { svc, _, u := newTestService(t) diff --git a/internal/files/fs.go b/internal/files/fs.go index 267a095..2a8e189 100644 --- a/internal/files/fs.go +++ b/internal/files/fs.go @@ -103,17 +103,28 @@ func (s *session) resolve(p string) (resolved, error) { // safeJoin joins rel onto root and verifies the result stays within root, both // lexically and after resolving any symlinks that already exist on the path. func safeJoin(root, rel string) (string, error) { + root = filepath.Clean(root) full := filepath.Join(root, filepath.FromSlash(rel)) full = filepath.Clean(full) if !within(root, full) { return "", errEscape } + // Symlink guard: resolve the longest existing prefix and re-check. This // catches a symlink (created out-of-band) that points outside the area. + // Canonicalize the area root too; otherwise a legitimate path under a root + // reached through an OS symlink (for example /var -> /private/var on macOS, or + // a symlinked data directory) can look like it escaped once EvalSymlinks is + // applied to the probe. + resolvedRoot := root + if rr, err := filepath.EvalSymlinks(root); err == nil { + resolvedRoot = filepath.Clean(rr) + } probe := full for { if resolvedPath, err := filepath.EvalSymlinks(probe); err == nil { - if !within(root, resolvedPath) { + resolvedPath = filepath.Clean(resolvedPath) + if !within(resolvedRoot, resolvedPath) { return "", errEscape } break