Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions internal/files/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion internal/files/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading