diff --git a/internal/gopher/gopher_test.go b/internal/gopher/gopher_test.go index 90a5290..f597cb3 100644 --- a/internal/gopher/gopher_test.go +++ b/internal/gopher/gopher_test.go @@ -181,6 +181,23 @@ func TestPathTraversalRefused(t *testing.T) { } } +func TestSymlinkEscapeRefused(t *testing.T) { + srv, dataDir := newTestServer(t) + home := filepath.Join(dataDir, "users", "alice", "public_html") + link := filepath.Join(home, "leak.txt") + if err := os.Symlink(filepath.Join(dataDir, "secret.txt"), link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + r := srv.Resolve("/~alice/leak.txt", false, "") + if r.Kind != KindError { + t.Fatalf("symlink escape should be refused, got %v", r.Kind) + } + if strings.Contains(r.Text, "TOP SECRET") || strings.Contains(string(r.Data), "TOP SECRET") { + t.Fatalf("symlink escape leaked the secret file: %+v", r) + } +} + func TestNewsPublicVsAuthed(t *testing.T) { srv, _ := newTestServer(t) diff --git a/internal/gopher/server.go b/internal/gopher/server.go index cedf8b5..170ccb7 100644 --- a/internal/gopher/server.go +++ b/internal/gopher/server.go @@ -303,8 +303,8 @@ func (s *Server) userTree(prefix, sel, area string) Response { base := filepath.Join(s.dataDir, "users", name, area) // Confine sub to base. Cleaning as an absolute path drops any leading ".." - // components; the HasPrefix re-check is belt-and-suspenders against symlink - // or edge cases. + // components; the HasPrefix re-check is belt-and-suspenders against edge + // cases before resolving symlinks below. clean := filepath.Clean("/" + sub) target := filepath.Join(base, clean) if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) { @@ -315,6 +315,17 @@ func (s *Server) userTree(prefix, sel, area string) Response { if err != nil { return errResp("not found") } + baseReal, err := filepath.EvalSymlinks(base) + if err != nil { + return errResp("not found") + } + targetReal, err := filepath.EvalSymlinks(target) + if err != nil { + return errResp("not found") + } + if targetReal != baseReal && !strings.HasPrefix(targetReal, baseReal+string(os.PathSeparator)) { + return errResp("forbidden") + } linkBase := "/~" + name if prefix == "files" { linkBase = "/files/~" + name @@ -322,9 +333,9 @@ func (s *Server) userTree(prefix, sel, area string) Response { rel := strings.TrimPrefix(strings.TrimPrefix(target, base), string(os.PathSeparator)) if info.IsDir() { - return s.dirMenu(target, linkBase, rel, name) + return s.dirMenu(targetReal, linkBase, rel, name) } - data, err := os.ReadFile(target) + data, err := os.ReadFile(targetReal) if err != nil { return errResp("unreadable") }