From e4c5db80f81ffd0a26f5af1616d0dbf537fc4bc7 Mon Sep 17 00:00:00 2001 From: Akimbo92i <249540823+Akimbo92i@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:06:49 +0000 Subject: [PATCH] fix(detect): canonicalize symlinked project roots --- detect/detect.go | 21 +++++++++-- detect/detect_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/detect/detect.go b/detect/detect.go index ca8962a..824d0e7 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -889,12 +889,27 @@ func (e *Engine) safeReadFileLimit(file string, limit int64) ([]byte, error) { return nil, err } if info.Mode()&os.ModeSymlink != 0 { - target, err := filepath.EvalSymlinks(path) + absPath, err := filepath.Abs(path) if err != nil { return nil, err } - absRoot, _ := filepath.Abs(e.Root) - if !strings.HasPrefix(target, absRoot+string(filepath.Separator)) { + target, err := filepath.EvalSymlinks(absPath) + if err != nil { + return nil, err + } + absRoot, err := filepath.Abs(e.Root) + if err != nil { + return nil, err + } + resolvedRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return nil, err + } + rel, err := filepath.Rel(resolvedRoot, target) + if err != nil { + return nil, err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { return nil, fmt.Errorf("symlink escapes project root: %s -> %s", file, target) } targetInfo, err := os.Stat(target) diff --git a/detect/detect_test.go b/detect/detect_test.go index 07e9215..4fcc490 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -1986,6 +1986,89 @@ func languageNames(r *brief.Report) []string { return names } +func TestSafeReadFileSymlinks(t *testing.T) { + t.Run("internal target with symlinked root", func(t *testing.T) { + parent := t.TempDir() + realRoot := filepath.Join(parent, "project") + if err := os.Mkdir(realRoot, 0o755); err != nil { + t.Fatal(err) + } + writeProjectFile(t, realRoot, "real_go.mod", "module example.com/internal\n") + + linkedRoot := filepath.Join(parent, "linked-project") + if err := os.Symlink(realRoot, linkedRoot); err != nil { + t.Skipf("creating root symlink: %v", err) + } + if err := os.Symlink("real_go.mod", filepath.Join(realRoot, "go.mod")); err != nil { + t.Skipf("creating file symlink: %v", err) + } + + data, err := New(nil, linkedRoot).safeReadFile("go.mod") + if err != nil { + t.Fatalf("safeReadFile: %v", err) + } + if got, want := string(data), "module example.com/internal\n"; got != want { + t.Errorf("safeReadFile returned %q, want %q", got, want) + } + }) + + t.Run("internal target with relative symlinked root", func(t *testing.T) { + parent := t.TempDir() + realRoot := filepath.Join(parent, "project") + if err := os.Mkdir(realRoot, 0o755); err != nil { + t.Fatal(err) + } + writeProjectFile(t, realRoot, "real_go.mod", "module example.com/relative\n") + if err := os.Symlink("real_go.mod", filepath.Join(realRoot, "go.mod")); err != nil { + t.Skipf("creating file symlink: %v", err) + } + + linkedRoot := filepath.Join(parent, "linked-project") + if err := os.Symlink("project", linkedRoot); err != nil { + t.Skipf("creating root symlink: %v", err) + } + t.Chdir(parent) + + data, err := New(nil, "linked-project").safeReadFile("go.mod") + if err != nil { + t.Fatalf("safeReadFile: %v", err) + } + if got, want := string(data), "module example.com/relative\n"; got != want { + t.Errorf("safeReadFile returned %q, want %q", got, want) + } + }) + + t.Run("external target", func(t *testing.T) { + root := t.TempDir() + externalRoot := t.TempDir() + writeProjectFile(t, externalRoot, "external.mod", "module example.com/external\n") + if err := os.Symlink(filepath.Join(externalRoot, "external.mod"), filepath.Join(root, "go.mod")); err != nil { + t.Skipf("creating file symlink: %v", err) + } + + data, err := New(nil, root).safeReadFile("go.mod") + if err == nil || !strings.Contains(err.Error(), "symlink escapes project root") { + t.Fatalf("safeReadFile error = %v, want symlink escape error", err) + } + if data != nil { + t.Errorf("safeReadFile returned %q for external target, want no data", data) + } + }) + + t.Run("regular file", func(t *testing.T) { + root := t.TempDir() + writeProjectFile(t, root, "go.mod", "module example.com/regular\n") + + data, err := New(nil, root).safeReadFile("go.mod") + if err != nil { + t.Fatalf("safeReadFile: %v", err) + } + if got, want := string(data), "module example.com/regular\n"; got != want { + t.Errorf("safeReadFile returned %q, want %q", got, want) + } + }) +} + func writeProjectFile(t *testing.T, dir, path, content string) { t.Helper() full := filepath.Join(dir, path)