package fbhttp import ( "archive/zip" "bytes" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/asdine/storm/v3" "github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/share" "github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/users" "github.com/spf13/afero" ) // symlinkShareStorage builds a storage whose single user is rooted at a real // on-disk scope containing a public share "/shared" with a symlinked // descendant "link -> ../private". Skips the test if symlinks are unavailable. func symlinkShareStorage(t *testing.T) *storage.Storage { t.Helper() scope := t.TempDir() if err := os.MkdirAll(filepath.Join(scope, "shared"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(scope, "private"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(scope, "private", "secret.txt"), []byte("symlink-secret"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(scope, "private"), filepath.Join(scope, "shared", "link")); err != nil { t.Skipf("cannot create symlink on this platform: %v", err) } db, err := storm.Open(filepath.Join(t.TempDir(), "db")) if err != nil { t.Fatalf("failed to open db: %v", err) } t.Cleanup(func() { _ = db.Close() }) st, err := bolt.NewStorage(db) if err != nil { t.Fatalf("failed to get storage: %v", err) } if err := st.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/shared"}); err != nil { t.Fatalf("failed to save share: %v", err) } if err := st.Users.Save(&users.User{ Username: "username", Password: "pw", Perm: users.Permissions{Share: true, Download: true}, }); err != nil { t.Fatalf("failed to save user: %v", err) } if err := st.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil { t.Fatalf("failed to save settings: %v", err) } st.Users = &customFSUser{ Store: st.Users, fs: files.NewScopedFs(afero.NewOsFs(), scope), } return st } // Reproduces GHSA-hf77-9m7w-fq8q: a public directory share whose subtree // contains a symlink to a directory outside the share. Requesting a regular // file behind that linked ancestor must NOT disclose its contents. func TestPublicShareSymlinkDescendantDisclosure(t *testing.T) { cases := map[string]struct { handler handleFunc path string }{ "direct file download via dl handler": {handler: publicDlHandler, path: "h/link/secret.txt"}, "share info via share handler": {handler: publicShareHandler, path: "h/link/secret.txt"}, "listing of linked dir": {handler: publicShareHandler, path: "h/link/"}, } for name, tc := range cases { tc := tc t.Run(name, func(t *testing.T) { st := symlinkShareStorage(t) req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = tc.path }) recorder := httptest.NewRecorder() handler := handle(tc.handler, "", st, &settings.Server{}) handler.ServeHTTP(recorder, req) result := recorder.Result() defer result.Body.Close() body, _ := io.ReadAll(result.Body) t.Logf("status=%d body=%q", result.StatusCode, string(body)) if result.StatusCode == http.StatusOK { t.Errorf("VULNERABLE: leaked path outside share (status 200, body=%q)", string(body)) } }) } } // The listing of the public share root must omit the escaping symlink "link" // entirely (no target metadata leak). func TestPublicShareSymlinkListingOmitsEscapingLink(t *testing.T) { st := symlinkShareStorage(t) req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/" }) recorder := httptest.NewRecorder() handler := handle(publicShareHandler, "", st, &settings.Server{}) handler.ServeHTTP(recorder, req) result := recorder.Result() defer result.Body.Close() body, _ := io.ReadAll(result.Body) if result.StatusCode != http.StatusOK { t.Fatalf("share root listing failed: status=%d body=%q", result.StatusCode, string(body)) } if strings.Contains(string(body), "\"link\"") { t.Errorf("VULNERABLE: listing exposes escaping symlink: %s", string(body)) } } // With Server.FollowExternalSymlinks enabled (the opt-in for issue #5998), a // symlink inside a public share that points outside it is followed: the file // behind it downloads and the link shows up in the share listing. This is the // inverse of TestPublicShareSymlinkDescendantDisclosure / ...ListingOmitsEscapingLink. func TestPublicShareSymlinkFollowedWhenEnabled(t *testing.T) { scope := t.TempDir() if err := os.MkdirAll(filepath.Join(scope, "shared"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(scope, "outside"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(scope, "outside", "data.txt"), []byte("payload"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(scope, "outside"), filepath.Join(scope, "shared", "link")); err != nil { t.Skipf("cannot create symlink on this platform: %v", err) } db, err := storm.Open(filepath.Join(t.TempDir(), "db")) if err != nil { t.Fatalf("failed to open db: %v", err) } t.Cleanup(func() { _ = db.Close() }) st, err := bolt.NewStorage(db) if err != nil { t.Fatalf("failed to get storage: %v", err) } if err := st.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/shared"}); err != nil { t.Fatalf("failed to save share: %v", err) } if err := st.Users.Save(&users.User{ Username: "username", Password: "pw", Perm: users.Permissions{Share: true, Download: true}, }); err != nil { t.Fatalf("failed to save user: %v", err) } if err := st.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil { t.Fatalf("failed to save settings: %v", err) } // Follow-external mode: the user filesystem is a bare BasePathFs. st.Users = &customFSUser{ Store: st.Users, fs: files.NewFs(afero.NewOsFs(), scope, true), followExternal: true, } srv := &settings.Server{FollowExternalSymlinks: true} // The file behind the symlink downloads. req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/link/data.txt" }) recorder := httptest.NewRecorder() handle(publicDlHandler, "", st, srv).ServeHTTP(recorder, req) result := recorder.Result() defer result.Body.Close() body, _ := io.ReadAll(result.Body) if result.StatusCode != http.StatusOK { t.Fatalf("expected to download file behind followed symlink, got status=%d body=%q", result.StatusCode, string(body)) } if !strings.Contains(string(body), "payload") { t.Fatalf("expected payload content, got %q", string(body)) } // The link shows up in the share root listing. req = newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/" }) recorder = httptest.NewRecorder() handle(publicShareHandler, "", st, srv).ServeHTTP(recorder, req) result = recorder.Result() defer result.Body.Close() body, _ = io.ReadAll(result.Body) if result.StatusCode != http.StatusOK { t.Fatalf("share root listing failed: status=%d body=%q", result.StatusCode, string(body)) } if !strings.Contains(string(body), "\"link\"") { t.Fatalf("expected followed symlink to appear in listing: %s", string(body)) } } // Reproduces the archive variant of GHSA-hf77-9m7w-fq8q: downloading the whole // public share as a zip must not pull in files reached through a symlinked // descendant. func TestPublicShareSymlinkArchiveDisclosure(t *testing.T) { st := symlinkShareStorage(t) // Request the whole share root as an archive. req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/" }) recorder := httptest.NewRecorder() handler := handle(publicDlHandler, "", st, &settings.Server{}) handler.ServeHTTP(recorder, req) result := recorder.Result() defer result.Body.Close() body, _ := io.ReadAll(result.Body) if result.StatusCode != http.StatusOK { t.Fatalf("archive request failed: status=%d body=%q", result.StatusCode, string(body)) } zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { t.Fatalf("failed to read zip: %v", err) } for _, f := range zr.File { if strings.Contains(f.Name, "secret.txt") { t.Errorf("VULNERABLE: archive includes file behind symlinked descendant: %q", f.Name) } rc, err := f.Open() if err != nil { continue } content, _ := io.ReadAll(rc) rc.Close() if bytes.Contains(content, []byte("symlink-secret")) { t.Errorf("VULNERABLE: archive entry %q leaks out-of-scope content", f.Name) } } }