diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5e61a5..83fb21fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [2.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04) + + +### Bug Fixes + +* avoid recursive conflict checks for copy and move ([#6009](https://github.com/filebrowser/filebrowser/issues/6009)) ([dfc2e88](https://github.com/filebrowser/filebrowser/commit/dfc2e887e1a19d54984a0d7e39a2a63caf73ef19)) +* deduplicate PT language ([4470288](https://github.com/filebrowser/filebrowser/commit/4470288ba1828a14453a99348fd22c62aa0b9460)) +* **preview:** keep the EPUB table-of-contents button clear of the header ([#6010](https://github.com/filebrowser/filebrowser/issues/6010)) ([aac2516](https://github.com/filebrowser/filebrowser/commit/aac25166378422135e624e305c410c54a39374fb)) + +## [2.63.17](https://github.com/filebrowser/filebrowser/compare/v2.63.16...v2.63.17) (2026-06-27) + + +### Bug Fixes + +* **auth:** reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) ([883a36f](https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387)) +* match admin share paths by owner scope ([#5992](https://github.com/filebrowser/filebrowser/issues/5992)) ([43a404c](https://github.com/filebrowser/filebrowser/commit/43a404ca69bf25553bfbbb2b446f0f53077c6302)) +* normalize recursive listing paths to forward slashes ([#6003](https://github.com/filebrowser/filebrowser/issues/6003)) ([2472fbc](https://github.com/filebrowser/filebrowser/commit/2472fbcd30502606feb11fbc8b8dc4f3803e6641)) +* preserve SRT subtitle line breaks ([#6002](https://github.com/filebrowser/filebrowser/issues/6002)) ([d9cf2f0](https://github.com/filebrowser/filebrowser/commit/d9cf2f0100d2c4892cad8e339eacca96df1aa5b6)) +* **raw:** neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) ([8503ba6](https://github.com/filebrowser/filebrowser/commit/8503ba61ff51d48a7313896483d130eb6a5abfe0)) +* **share:** delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) ([f30fca6](https://github.com/filebrowser/filebrowser/commit/f30fca636c1af9ef401e9a82ff60391cb3db97e1)) +* **share:** stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) ([ec13054](https://github.com/filebrowser/filebrowser/commit/ec130546713c44cd24556907552ac554c7f809c9)) + +## [2.63.16](https://github.com/filebrowser/filebrowser/compare/v2.63.15...v2.63.16) (2026-06-23) + + +### Bug Fixes + +* dangling symlink, write, delete scope bugs ([64511ce](https://github.com/filebrowser/filebrowser/commit/64511ce45e3be379e965f7f4fb0929a068d5bb81)) +* restore symlink behavior as opt-in followExternalSymlinks ([a106392](https://github.com/filebrowser/filebrowser/commit/a1063925e15ef27f9d5dc26aae371bbf52af608c)) + ## [2.63.15](https://github.com/filebrowser/filebrowser/compare/v2.63.14...v2.63.15) (2026-06-13) diff --git a/auth/hook.go b/auth/hook.go index 60c75461..a6dc25b8 100644 --- a/auth/hook.go +++ b/auth/hook.go @@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings case "block": return nil, os.ErrPermission case "pass": - u, err := a.Users.Get(a.Server.Root, a.Cred.Username) + u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username) if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) { return nil, os.ErrPermission } @@ -129,7 +129,7 @@ func (a *HookAuth) GetValues(s string) { // SaveUser updates the existing user or creates a new one when not found func (a *HookAuth) SaveUser() (*users.User, error) { - u, err := a.Users.Get(a.Server.Root, a.Cred.Username) + u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username) if err != nil && !errors.Is(err, fberrors.ErrNotExist) { return nil, err } diff --git a/auth/json.go b/auth/json.go index 2284dc7f..be8ad1dc 100644 --- a/auth/json.go +++ b/auth/json.go @@ -55,7 +55,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s } } - u, err := usr.Get(srv.Root, cred.Username) + u, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, cred.Username) hash := dummyHash if err == nil { diff --git a/auth/none.go b/auth/none.go index c9381a83..30ea7129 100644 --- a/auth/none.go +++ b/auth/none.go @@ -15,7 +15,7 @@ type NoAuth struct{} // Auth uses authenticates user 1. func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) { - return usr.Get(srv.Root, uint(1)) + return usr.Get(srv.Root, srv.FollowExternalSymlinks, uint(1)) } // LoginPage tells that no auth doesn't require a login page. diff --git a/auth/proxy.go b/auth/proxy.go index 57eddd4a..ab6227d4 100644 --- a/auth/proxy.go +++ b/auth/proxy.go @@ -20,7 +20,7 @@ type ProxyAuth struct { // Auth authenticates the user via an HTTP header. func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) { username := r.Header.Get(a.Header) - user, err := usr.Get(srv.Root, username) + user, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, username) if errors.Is(err, fberrors.ErrNotExist) { return a.createUser(usr, setting, srv, username) } diff --git a/auth/proxy_test.go b/auth/proxy_test.go index 86fb7102..9b9ef3a7 100644 --- a/auth/proxy_test.go +++ b/auth/proxy_test.go @@ -13,7 +13,7 @@ type mockUserStore struct { users map[string]*users.User } -func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) { +func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, error) { if v, ok := id.(string); ok { if u, ok := m.users[v]; ok { return u, nil @@ -22,14 +22,15 @@ func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) { return nil, fberrors.ErrNotExist } -func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil } -func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil } +func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist } +func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil } +func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil } func (m *mockUserStore) Save(user *users.User) error { m.users[user.Username] = user return nil } func (m *mockUserStore) Delete(_ interface{}) error { return nil } -func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 } +func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 } func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) { t.Parallel() diff --git a/cmd/config.go b/cmd/config.go index e3bb2b86..cf923eac 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -229,6 +229,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails) fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview) fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader) + fmt.Fprintf(w, "\tFollow External Symlinks:\t%t\n", ser.FollowExternalSymlinks) fmt.Fprintln(w, "\nTUS:") fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize) diff --git a/cmd/root.go b/cmd/root.go index 13139a7e..1d34f866 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -111,6 +112,7 @@ func addServerFlags(flags *pflag.FlagSet) { flags.Bool("disableExec", true, "disables Command Runner feature") flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers") flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files") + flags.Bool("followExternalSymlinks", false, "follow symlinks whose target is outside the user scope (unsafe)") } var rootCmd = &cobra.Command{ @@ -353,6 +355,10 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e server.EnableExec = !v.GetBool("disableExec") } + if v.IsSet("followExternalSymlinks") { + server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks") + } + if isAddrSet && isSocketSet { return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert") } @@ -369,6 +375,25 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199") } + if server.FollowExternalSymlinks { + log.Println("WARNING: Following external symlinks enabled!") + log.Println("WARNING: Symlinks pointing outside a user's scope will be followed,") + log.Println("WARNING: which can expose files outside that scope. Only enable this if") + log.Println("WARNING: you fully understand and trust the contents of every user scope.") + } + + if set, err := st.Settings.Get(); err == nil && set.Signup { + scope := strings.TrimSpace(set.Defaults.Scope) + scopeIsRoot := scope == "" || scope == "." || scope == "/" + + if !set.CreateUserDir && scopeIsRoot { + log.Println("WARNING: Signup is enabled without createUserDir and the default scope is") + log.Println("WARNING: the server root, so every self-registered user can read, modify and") + log.Println("WARNING: delete all files File Browser serves, including other users' files.") + log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.") + } + } + return server, nil } @@ -446,19 +471,20 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error { } ser := &settings.Server{ - BaseURL: v.GetString("baseURL"), - Port: v.GetString("port"), - Log: v.GetString("log"), - TLSKey: v.GetString("key"), - TLSCert: v.GetString("cert"), - Address: v.GetString("address"), - Root: v.GetString("root"), - TokenExpirationTime: v.GetString("tokenExpirationTime"), - EnableThumbnails: !v.GetBool("disableThumbnails"), - ResizePreview: !v.GetBool("disablePreviewResize"), - EnableExec: !v.GetBool("disableExec"), - TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"), - ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"), + BaseURL: v.GetString("baseURL"), + Port: v.GetString("port"), + Log: v.GetString("log"), + TLSKey: v.GetString("key"), + TLSCert: v.GetString("cert"), + Address: v.GetString("address"), + Root: v.GetString("root"), + TokenExpirationTime: v.GetString("tokenExpirationTime"), + EnableThumbnails: !v.GetBool("disableThumbnails"), + ResizePreview: !v.GetBool("disablePreviewResize"), + EnableExec: !v.GetBool("disableExec"), + TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"), + ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"), + FollowExternalSymlinks: v.GetBool("followExternalSymlinks"), } err = s.Settings.SaveServer(ser) diff --git a/cmd/rules.go b/cmd/rules.go index bdb1d1cf..f3e00bb6 100644 --- a/cmd/rules.go +++ b/cmd/rules.go @@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User) } if id != nil { var user *users.User - user, err = st.Users.Get("", id) + user, err = st.Users.Get("", false, id) if err != nil { return err } diff --git a/cmd/users_export.go b/cmd/users_export.go index 9bbec6d8..768c381f 100644 --- a/cmd/users_export.go +++ b/cmd/users_export.go @@ -15,7 +15,7 @@ var usersExportCmd = &cobra.Command{ path to the file where you want to write the users.`, Args: jsonYamlArg, RunE: withStore(func(_ *cobra.Command, args []string, st *store) error { - list, err := st.Users.Gets("") + list, err := st.Users.Gets("", false) if err != nil { return err } diff --git a/cmd/users_find.go b/cmd/users_find.go index 09bc8d47..fc91af86 100644 --- a/cmd/users_find.go +++ b/cmd/users_find.go @@ -36,14 +36,14 @@ var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error if len(args) == 1 { username, id := parseUsernameOrID(args[0]) if username != "" { - user, err = st.Users.Get("", username) + user, err = st.Users.Get("", false, username) } else { - user, err = st.Users.Get("", id) + user, err = st.Users.Get("", false, id) } list = []*users.User{user} } else { - list, err = st.Users.Gets("") + list, err = st.Users.Gets("", false) } if err != nil { diff --git a/cmd/users_import.go b/cmd/users_import.go index 73effca6..0db3704f 100644 --- a/cmd/users_import.go +++ b/cmd/users_import.go @@ -40,7 +40,7 @@ list or set it to 0.`, } for _, user := range list { - err = user.Clean("") + err = user.Clean("", false) if err != nil { return err } @@ -52,7 +52,7 @@ list or set it to 0.`, } if replace { - oldUsers, userImportErr := st.Users.Gets("") + oldUsers, userImportErr := st.Users.Gets("", false) if userImportErr != nil { return userImportErr } @@ -76,7 +76,7 @@ list or set it to 0.`, } for _, user := range list { - onDB, err := st.Users.Get("", user.ID) + onDB, err := st.Users.Get("", false, user.ID) // User exists in DB. if err == nil { @@ -88,7 +88,7 @@ list or set it to 0.`, // with the new username. If there is, print an error and cancel the // operation if user.Username != onDB.Username { - if conflictuous, err := st.Users.Get("", user.Username); err == nil { + if conflictuous, err := st.Users.Get("", false, user.Username); err == nil { return usernameConflictError(user.Username, conflictuous.ID, user.ID) } } diff --git a/cmd/users_update.go b/cmd/users_update.go index e9a484fc..12484342 100644 --- a/cmd/users_update.go +++ b/cmd/users_update.go @@ -43,9 +43,9 @@ options you want to change.`, user *users.User ) if id != 0 { - user, err = st.Users.Get("", id) + user, err = st.Users.Get("", false, id) } else { - user, err = st.Users.Get("", username) + user, err = st.Users.Get("", false, username) } if err != nil { return err diff --git a/files/file_test.go b/files/file_test.go index b5a8b37f..45b114f6 100644 --- a/files/file_test.go +++ b/files/file_test.go @@ -92,6 +92,125 @@ func TestScopedFs(t *testing.T) { t.Fatalf("expected in-scope symlink target to be accessible, got %v", err) } }) + + // Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 / + // GHSA-fh54-6rfh-r8f3): a symlink whose target does not exist yet must not be + // followed for writes. Previously within() validated the link's in-scope + // parent directory, so OpenFile(O_CREATE) dereferenced the link and created + // the file at its out-of-scope target. + t.Run("write through a dangling escaping symlink is rejected", func(t *testing.T) { + base := t.TempDir() + scope := filepath.Join(base, "scope") + outside := filepath.Join(base, "outside") + for _, d := range []string{scope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + outsideTarget := filepath.Join(outside, "created.txt") // does not exist yet + if err := os.Symlink(outsideTarget, filepath.Join(scope, "evil")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + fs := NewScopedFs(afero.NewOsFs(), scope) + + f, err := fs.OpenFile("/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err == nil { + _ = f.Close() + t.Fatal("VULNERABLE: write through a dangling escaping symlink was allowed") + } + if !os.IsPermission(err) { + t.Fatalf("expected permission error, got %v", err) + } + if _, statErr := os.Stat(outsideTarget); statErr == nil { + t.Fatal("VULNERABLE: file was created outside the scope") + } + }) + + // A dangling *relative* symlink that lives under an escaping directory + // symlink must be resolved against the link's real directory, not its lexical + // parent. Otherwise the symlinked ancestor can shift the computed target back + // into scope while the real OS write lands outside it. + t.Run("write through a dangling relative symlink under a symlinked dir is rejected", func(t *testing.T) { + base := t.TempDir() + scope := filepath.Join(base, "scope") + outside := filepath.Join(base, "outside") + for _, d := range []string{scope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + // An escaping directory symlink inside the scope: /scope/m -> /base/outside. + if err := os.Symlink(outside, filepath.Join(scope, "m")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + // A relative dangling symlink inside the escaping dir whose target, + // resolved against the real directory (/base/outside), is /base/escaped — + // outside the scope. + if err := os.Symlink("../escaped", filepath.Join(outside, "evil")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + fs := NewScopedFs(afero.NewOsFs(), scope) + + f, err := fs.OpenFile("/m/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err == nil { + _ = f.Close() + t.Fatal("VULNERABLE: write through a dangling relative symlink under a symlinked dir was allowed") + } + if !os.IsPermission(err) { + t.Fatalf("expected permission error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(base, "escaped")); statErr == nil { + t.Fatal("VULNERABLE: file was created outside the scope") + } + }) + + // Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp / + // GHSA-fmm7-x4gx-8jhr): Remove/RemoveAll used to skip guard(), so RemoveAll + // followed a symlinked ancestor escaping the scope and deleted an + // out-of-scope file. + t.Run("RemoveAll through an escaping symlink is rejected", func(t *testing.T) { + base := t.TempDir() + scope := filepath.Join(base, "scope") + outside := filepath.Join(base, "outside") + for _, d := range []string{scope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + victim := filepath.Join(outside, "victim.txt") + if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(scope, "link")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + fs := NewScopedFs(afero.NewOsFs(), scope) + + if err := fs.RemoveAll("/link/victim.txt"); !os.IsPermission(err) { + t.Fatalf("expected RemoveAll through escaping symlink to be rejected, got %v", err) + } + if _, statErr := os.Stat(victim); statErr != nil { + t.Fatalf("VULNERABLE: out-of-scope victim file was deleted: %v", statErr) + } + }) + + // The guard added for the delete escape must not break legitimate deletes of + // in-scope files. + t.Run("RemoveAll of an in-scope file is allowed", func(t *testing.T) { + scope := t.TempDir() + target := filepath.Join(scope, "deleteme.txt") + if err := os.WriteFile(target, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + fs := NewScopedFs(afero.NewOsFs(), scope) + + if err := fs.RemoveAll("/deleteme.txt"); err != nil { + t.Fatalf("expected in-scope RemoveAll to succeed, got %v", err) + } + if _, statErr := os.Stat(target); statErr == nil { + t.Fatal("expected in-scope file to be deleted") + } + }) } // stat must reject a regular file reached through a symlinked ancestor that diff --git a/files/fs_test.go b/files/fs_test.go new file mode 100644 index 00000000..5974b20a --- /dev/null +++ b/files/fs_test.go @@ -0,0 +1,121 @@ +package files + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" +) + +// TestNewFs verifies that NewFs picks the right implementation and that the +// follow-external-symlinks toggle flips whether a symlink pointing outside the +// scope is honored. +func TestNewFs(t *testing.T) { + base := t.TempDir() + scope := filepath.Join(base, "srv") + outside := filepath.Join(base, "outside") + for _, d := range []string{scope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + // A symlink lexically inside the scope whose target resolves outside it. + if err := os.Symlink(outside, filepath.Join(scope, "escape")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + t.Run("disabled returns a ScopedFs that rejects the escaping symlink", func(t *testing.T) { + fs := NewFs(afero.NewOsFs(), scope, false) + if _, ok := fs.(*ScopedFs); !ok { + t.Fatalf("expected *ScopedFs, got %T", fs) + } + if _, err := fs.Stat("/escape"); !os.IsPermission(err) { + t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err) + } + }) + + t.Run("enabled returns a BasePathFs that follows the escaping symlink", func(t *testing.T) { + fs := NewFs(afero.NewOsFs(), scope, true) + if _, ok := fs.(*afero.BasePathFs); !ok { + t.Fatalf("expected *afero.BasePathFs, got %T", fs) + } + if _, err := fs.Stat("/escape"); err != nil { + t.Fatalf("expected escaping symlink to be followed, got %v", err) + } + b, err := afero.ReadFile(fs, "/escape/secret.txt") + if err != nil { + t.Fatalf("expected to read through escaping symlink, got %v", err) + } + if string(b) != "secret" { + t.Fatalf("got %q, want %q", b, "secret") + } + + // The link must also appear in a directory listing (the symptom in #5998). + entries, err := afero.ReadDir(fs, "/") + if err != nil { + t.Fatal(err) + } + var found bool + for _, e := range entries { + if e.Name() == "escape" { + found = true + } + } + if !found { + t.Fatal("expected escaping symlink to appear in the listing") + } + }) +} + +// TestBasePath verifies BasePath extracts the underlying *afero.BasePathFs from +// either filesystem NewFs may return, so User.FullPath keeps working. +func TestBasePath(t *testing.T) { + root := t.TempDir() + osFs := afero.NewOsFs() + + for _, tc := range []struct { + name string + followExternal bool + }{ + {"ScopedFs", false}, + {"BasePathFs", true}, + } { + t.Run(tc.name, func(t *testing.T) { + fs := NewFs(osFs, root, tc.followExternal) + base := BasePath(fs) + if base == nil { + t.Fatalf("expected non-nil base for %T", fs) + } + got := afero.FullBaseFsPath(base, "/x") + want := filepath.Join(root, "x") + if got != want { + t.Fatalf("FullBaseFsPath: got %q, want %q", got, want) + } + }) + } + + if got := BasePath(osFs); got != nil { + t.Fatalf("expected nil base for a plain OsFs, got %v", got) + } +} + +// TestFileInfoRealPathUsesBasePathFsRealPath mirrors +// TestFileInfoRealPathUsesScopedFsRealPath for the follow-external-symlinks case, +// where the user filesystem is a bare BasePathFs. +func TestFileInfoRealPathUsesBasePathFsRealPath(t *testing.T) { + root := t.TempDir() + file := &FileInfo{ + Fs: NewFs(afero.NewOsFs(), root, true), + Path: "/root/downloads", + } + + got := file.RealPath() + want := filepath.Join(root, "root", "downloads") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/files/scoped.go b/files/scoped.go index eef6378b..97ce32d0 100644 --- a/files/scoped.go +++ b/files/scoped.go @@ -25,6 +25,11 @@ var ( _ afero.Lstater = (*ScopedFs)(nil) ) +// maxSymlinkHops bounds how many dangling symlinks within() will follow before +// giving up, so a pathological chain cannot loop forever. It mirrors the kernel +// MAXSYMLINKS limit; the operation is rejected once the bound is exceeded. +const maxSymlinkHops = 255 + func NewScopedFs(source afero.Fs, path string) *ScopedFs { if s, ok := source.(*ScopedFs); ok { source = s.base @@ -32,8 +37,31 @@ func NewScopedFs(source afero.Fs, path string) *ScopedFs { return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)} } -// Base returns the underlying *afero.BasePathFs. -func (s *ScopedFs) Base() *afero.BasePathFs { return s.base } +// NewFs builds a user filesystem rooted at path. When followExternal is true it +// returns a bare BasePathFs, so symlinks whose target resolves outside the scope +// are followed; otherwise it returns a ScopedFs that refuses to follow them. +func NewFs(source afero.Fs, path string, followExternal bool) afero.Fs { + if followExternal { + return afero.NewBasePathFs(source, path) + } + return NewScopedFs(source, path) +} + +// BasePath returns the underlying *afero.BasePathFs of a user filesystem built +// by NewFs, whether it is a *ScopedFs or a bare *afero.BasePathFs, or nil if it +// is neither. +func BasePath(fs afero.Fs) *afero.BasePathFs { + switch f := fs.(type) { + case *ScopedFs: + return f.BasePathFs() + case *afero.BasePathFs: + return f + } + return nil +} + +// BasePathFs returns the underlying *afero.BasePathFs. +func (s *ScopedFs) BasePathFs() *afero.BasePathFs { return s.base } // RealPath resolves a scoped path to the real on-disk path by delegating to // the underlying BasePathFs. This is needed by callers that need the actual @@ -61,13 +89,10 @@ func (s *ScopedFs) guard(name string) error { // // Paths that do not exist yet (e.g. a brand-new file being created) are // validated against their nearest existing ancestor, so legitimate new files -// are always allowed. -// -// Note: a dangling symlink whose target does not yet exist resolves to its -// containing directory and is therefore allowed; writing through such a link -// could still create a file outside the scope. This is treated as best-effort -// and relies on rejecting existing escaping symlinks, which covers the -// disclosure and overwrite vectors. +// are always allowed. A dangling symlink — a link whose target does not exist +// yet — is the exception: it is followed to where it points and validated +// there, so a write cannot dereference the link to create a file outside the +// scope. func (s *ScopedFs) within(p string) (bool, error) { root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/")) if err != nil { @@ -76,12 +101,43 @@ func (s *ScopedFs) within(p string) (bool, error) { target := afero.FullBaseFsPath(s.base, p) resolved, err := filepath.EvalSymlinks(target) - for errors.Is(err, fs.ErrNotExist) { - parent := filepath.Dir(target) - if parent == target { - break + // When target does not resolve, work out where the operation would actually + // land. A non-existent regular path resolves to the file that would be + // created inside its containing directory, so walk up to the nearest + // existing ancestor and validate that. But when target itself is a dangling + // symlink, follow it one level instead: validating its lexical parent would + // wrongly accept a link pointing outside the scope, letting a write follow + // the link and create the file out of bounds. + for hops := 0; errors.Is(err, fs.ErrNotExist); { + if fi, lerr := os.Lstat(target); lerr == nil && fi.Mode()&os.ModeSymlink != 0 { + hops++ + if hops > maxSymlinkHops { + return false, os.ErrPermission + } + dest, rerr := os.Readlink(target) + if rerr != nil { + return false, rerr + } + if !filepath.IsAbs(dest) { + // Resolve the link relative to the directory that really contains + // it, not its lexical parent: a symlinked ancestor could otherwise + // shift the computed target back into scope while the real write + // lands outside it. The parent is guaranteed to resolve here + // because os.Lstat above already traversed it. + base, berr := filepath.EvalSymlinks(filepath.Dir(target)) + if berr != nil { + return false, berr + } + dest = filepath.Join(base, dest) + } + target = filepath.Clean(dest) + } else { + parent := filepath.Dir(target) + if parent == target { + break + } + target = parent } - target = parent resolved, err = filepath.EvalSymlinks(target) } if err != nil { @@ -136,10 +192,16 @@ func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File } func (s *ScopedFs) Remove(name string) error { + if err := s.guard(name); err != nil { + return err + } return s.base.Remove(name) } func (s *ScopedFs) RemoveAll(path string) error { + if err := s.guard(path); err != nil { + return err + } return s.base.RemoveAll(path) } diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue index 282902fb..c51904d1 100644 --- a/frontend/src/components/prompts/Share.vue +++ b/frontend/src/components/prompts/Share.vue @@ -38,7 +38,7 @@ class="action" :aria-label="$t('buttons.copyDownloadLinkToClipboard')" :title="$t('buttons.copyDownloadLinkToClipboard')" - :disabled="!!link.password_hash" + :disabled="!!link.hasPassword" @click="copyToClipboard(buildDownloadLink(link))" > content_paste_go diff --git a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts index cc138329..710baf0d 100644 --- a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts +++ b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts @@ -71,7 +71,9 @@ const event = { describe("copy and move conflict prompts", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(checkConflict).mockResolvedValue(conflict); + vi.mocked(checkConflict).mockResolvedValue( + conflict as ConflictingResource[] + ); }); it("waits for copy conflict detection before calling the copy API", async () => { diff --git a/frontend/src/css/epubReader.css b/frontend/src/css/epubReader.css index a575fb27..c8ceb110 100644 --- a/frontend/src/css/epubReader.css +++ b/frontend/src/css/epubReader.css @@ -29,6 +29,13 @@ .epub-reader .tocButton { color: var(--text); + /* + * vue-reader positions the TOC toggle at top: 10px, which lands under the + * fixed 4em header bar (see .header) — so clicks hit the header's Close + * button instead of opening the table of contents. Push it just below the + * header, tracking the header's height so it stays clear if that changes. + */ + top: calc(4em + 10px); } .epub-reader .tocButton.tocButtonExpanded { diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json index 1accf5f9..fd4170d5 100644 --- a/frontend/src/i18n/ko.json +++ b/frontend/src/i18n/ko.json @@ -29,8 +29,8 @@ "rename": "이름 바꾸기", "replace": "대체", "reportIssue": "문제 보고", - "resumeTransfer": "Resume previous transfer", - "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.", + "resumeTransfer": "이전 전송을 재개", + "resumeTransferTooltip": "서버에 있는 파일 중 전송이 중단되었을 가능성이 있는 작은 파일을 제외하고 충돌하는 모든 파일을 건너뜁니다.", "save": "저장", "schedule": "일정", "search": "검색", @@ -283,7 +283,7 @@ "currentPassword": "현재 비밀번호" }, "sidebar": { - "diskUsed": "{used} of {total} used", + "diskUsed": "{total} 중 {used} 사용", "help": "도움말", "hugoNew": "Hugo New", "login": "로그인", diff --git a/frontend/src/i18n/pt-pt.json b/frontend/src/i18n/pt-pt.json index 2c04e8aa..721a759b 100644 --- a/frontend/src/i18n/pt-pt.json +++ b/frontend/src/i18n/pt-pt.json @@ -238,7 +238,7 @@ "newPasswordConfirm": "Confirmar a sua palavra-passe", "newUser": "Novo Utilizador", "password": "Palavra-passe", - "passwordUpdated": "Palavras-passe não coincidem!", + "passwordUpdated": "Palavra-passe atualizada!", "path": "Caminho", "perm": { "create": "Criar ficheiros e pastas", diff --git a/frontend/src/i18n/pt.json b/frontend/src/i18n/pt.json deleted file mode 100644 index d0523251..00000000 --- a/frontend/src/i18n/pt.json +++ /dev/null @@ -1,309 +0,0 @@ -{ - "buttons": { - "cancel": "Cancelar", - "clear": "Limpar", - "close": "Fechar", - "continue": "Continuar", - "copy": "Copiar", - "copyFile": "Copiar ficheiro", - "copyToClipboard": "Copiar", - "copyDownloadLinkToClipboard": "Copy download link to clipboard", - "create": "Criar", - "delete": "Eliminar", - "download": "Descarregar", - "file": "File", - "folder": "Folder", - "fullScreen": "Toggle full screen", - "hideDotfiles": "Hide dotfiles", - "info": "Info", - "more": "Mais", - "move": "Mover", - "moveFile": "Mover ficheiro", - "new": "Novo", - "next": "Próximo", - "ok": "OK", - "permalink": "Obter link permanente", - "previous": "Anterior", - "preview": "Preview", - "publish": "Publicar", - "rename": "Alterar nome", - "replace": "Substituir", - "reportIssue": "Reportar erro", - "resumeTransfer": "Resume previous transfer", - "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.", - "save": "Guardar", - "schedule": "Agendar", - "search": "Pesquisar", - "select": "Selecionar", - "selectMultiple": "Selecionar vários", - "share": "Partilhar", - "shell": "Alternar shell", - "submit": "Submit", - "switchView": "Alterar vista", - "toggleSidebar": "Alternar barra lateral", - "update": "Atualizar", - "upload": "Enviar", - "openFile": "Open file", - "openDirect": "View raw", - "discardChanges": "Discard", - "stopSearch": "Stop searching", - "saveChanges": "Save changes", - "editAsText": "Edit as Text", - "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size", - "overrideAll": "Replace all files in destination folder", - "skipAll": "Skip all conflicting files", - "renameAll": "Rename all files (create a copy)", - "singleDecision": "Decide for each conflicting file" - }, - "download": { - "downloadFile": "Descarregar ficheiro", - "downloadFolder": "Descarregar pasta", - "downloadSelected": "Download Selected" - }, - "upload": { - "abortUpload": "Are you sure you wish to abort?" - }, - "errors": { - "forbidden": "Não tem permissões para aceder a isto", - "internal": "Algo correu bastante mal.", - "notFound": "Esta localização não é alcançável.", - "connection": "The server can't be reached." - }, - "files": { - "body": "Corpo", - "closePreview": "Fechar pré-visualização", - "files": "Ficheiros", - "folders": "Pastas", - "home": "Início", - "lastModified": "Última alteração", - "loading": "A carregar...", - "lonely": "Sinto-me sozinho...", - "metadata": "Metadados", - "multipleSelectionEnabled": "Seleção múltipla ativada", - "name": "Nome", - "size": "Tamanho", - "sortByLastModified": "Ordenar pela última alteração", - "sortByName": "Ordenar pelo nome", - "sortBySize": "Ordenar pelo tamanho", - "noPreview": "Preview is not available for this file.", - "csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.", - "csvLoadFailed": "Failed to load CSV file.", - "showingRows": "Showing {count} row(s)", - "columnSeparator": "Column Separator", - "csvSeparators": { - "comma": "Comma (,)", - "semicolon": "Semicolon (;)", - "both": "Both (,) and (;)" - }, - "fileEncoding": "File Encoding" - }, - "help": { - "click": "selecionar pasta ou ficheiro", - "ctrl": { - "click": "selecionar várias pastas e ficheiros", - "f": "pesquisar", - "s": "guardar um ficheiro ou descarrega a pasta em que está a navegar" - }, - "del": "eliminar os ficheiros selecionados", - "doubleClick": "abrir pasta ou ficheiro", - "esc": "limpar seleção e/ou fechar menu", - "f1": "esta informação", - "f2": "alterar nome do ficheiro", - "help": "Ajuda" - }, - "login": { - "createAnAccount": "Criar uma conta", - "loginInstead": "Já tenho uma conta", - "password": "Palavra-passe", - "passwordConfirm": "Confirmação da palavra-passe", - "passwordsDontMatch": "As palavras-passe não coincidem", - "signup": "Registar", - "submit": "Entrar na conta", - "username": "Nome de utilizador", - "usernameTaken": "O nome de utilizador já está registado", - "wrongCredentials": "Dados errados", - "passwordTooShort": "Password must be at least {min} characters", - "logout_reasons": { - "inactivity": "You have been logged out due to inactivity." - } - }, - "permanent": "Permanente", - "prompts": { - "copy": "Copiar", - "copyMessage": "Escolha um lugar para onde copiar os ficheiros:", - "currentlyNavigating": "A navegar em:", - "deleteMessageMultiple": "Quer eliminar {count} ficheiro(s)?", - "deleteMessageSingle": "Quer eliminar esta pasta/ficheiro?", - "deleteMessageShare": "Are you sure you wish to delete this share({path})?", - "deleteUser": "Are you sure you want to delete this user?", - "deleteTitle": "Eliminar ficheiros", - "displayName": "Nome:", - "download": "Descarregar ficheiros", - "downloadMessage": "Escolha o formato do ficheiro que quer descarregar.", - "error": "Algo correu mal", - "fileInfo": "Informação do ficheiro", - "filesSelected": "{count} ficheiros selecionados.", - "lastModified": "Última alteração", - "move": "Mover", - "moveMessage": "Escolha uma nova casa para os seus ficheiros/pastas:", - "newArchetype": "Criar um novo post baseado num \"archetype\". O seu ficheiro será criado na pasta \"content\".", - "newDir": "Nova pasta", - "newDirMessage": "Escreva o nome da nova pasta.", - "newFile": "Novo ficheiro", - "newFileMessage": "Escreva o nome do novo ficheiro.", - "numberDirs": "Número de pastas", - "numberFiles": "Número de ficheiros", - "rename": "Alterar nome", - "renameMessage": "Insira um novo nome para", - "replace": "Substituir", - "replaceMessage": "Já existe um ficheiro com nome igual a um dos que está a tentar enviar. Quer substituí-lo?\n", - "schedule": "Agendar", - "scheduleMessage": "Escolha uma data para publicar este post.", - "show": "Mostrar", - "size": "Tamanho", - "upload": "Upload", - "uploadFiles": "Uploading {files} files...", - "uploadMessage": "Select an option to upload.", - "optionalPassword": "Optional password", - "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", - "replaceOrSkip": "Replace or skip files", - "resolveConflict": "Which files do you want to keep?", - "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", - "fastConflictResolve": "The destination folder there are {count} files with same name.", - "uploadingFiles": "Uploading files", - "filesInOrigin": "Files in origin", - "filesInDest": "Files in destination", - "override": "Overwrite", - "skip": "Skip", - "forbiddenError": "Forbidden Error", - "currentPassword": "Your password", - "currentPasswordMessage": "Enter your password to validate this action." - }, - "search": { - "images": "Imagens", - "music": "Música", - "pdf": "PDF", - "pressToSearch": "Tecla Enter para pesquisar...", - "search": "Pesquisar...", - "typeToSearch": "Escrever para pesquisar...", - "types": "Tipos", - "video": "Vídeos" - }, - "settings": { - "aceEditorTheme": "Ace editor theme", - "admin": "Admin", - "administrator": "Administrador", - "allowCommands": "Executar comandos", - "allowEdit": "Editar, renomear e eliminar ficheiros ou pastas", - "allowNew": "Criar novos ficheiros e pastas", - "allowPublish": "Publicar novas páginas e conteúdos", - "allowSignup": "Permitir que os utilizadores criem contas", - "hideLoginButton": "Hide the login button from public pages", - "avoidChanges": "(deixe em branco para manter)", - "branding": "Marca", - "brandingDirectoryPath": "Caminho da pasta de marca", - "brandingHelp": "Pode personalizar a aparência do seu Navegador de Ficheiros, alterar o nome, substituindo o logótipo, adicionando estilos personalizados e mesmo desativando links externos para o GitHub.\nPara mais informações sobre marca personalizada, por favor veja {0}.", - "changePassword": "Alterar palavra-passe", - "commandRunner": "Execução de comandos", - "commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Tem de escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para mais informações sobre esta funcionalidade e as variáveis de ambiente, veja {2}.", - "commandsUpdated": "Comandos atualizados!", - "createUserDir": "Criar automaticamente a pasta de início ao adicionar um novo utilizador", - "minimumPasswordLength": "Minimum password length", - "tusUploads": "Chunked Uploads", - "tusUploadsHelp": "File Browser supports chunked file uploads, allowing for the creation of efficient, reliable, resumable and chunked file uploads even on unreliable networks.", - "tusUploadsChunkSize": "Indicates to maximum size of a request (direct uploads will be used for smaller uploads). You may input a plain integer denoting byte size input or a string like 10MB, 1GB etc.", - "tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.", - "userHomeBasePath": "Base path for user home directories", - "userScopeGenerationPlaceholder": "The scope will be auto generated", - "createUserHomeDirectory": "Create user home directory", - "customStylesheet": "Folha de estilos personalizada", - "defaultUserDescription": "Estas são as configurações padrão para novos utilizadores.", - "disableExternalLinks": "Desativar links externos (exceto documentação)", - "disableUsedDiskPercentage": "Disable used disk percentage graph", - "documentation": "documentação", - "examples": "Exemplos", - "executeOnShell": "Executar na shell", - "executeOnShellDescription": "Por padrão, o Navegador de Ficheiros executa os comandos chamando os seus binários diretamente. Se em vez disso, quiser executá-los numa shell (como Bash ou PowerShell), pode definir isso aqui com os argumentos e bandeiras necessários. Se definido, o comando que executa será anexado como um argumento. Isto aplica-se tanto a comandos do utilizador como a hooks de eventos.", - "globalRules": "Isto é um conjunto global de regras de permissão e negação. Elas aplicam-se a todos os utilizadores. Pode especificar regras específicas para cada configuração do utilizador para sobreporem-se a estas.", - "globalSettings": "Configurações globais", - "hideDotfiles": "Hide dotfiles", - "insertPath": "Inserir o caminho", - "insertRegex": "Inserir expressão regular", - "instanceName": "Nome da instância", - "language": "Linguagem", - "lockPassword": "Não permitir que o utilizador altere a palavra-passe", - "newPassword": "Nova palavra-passe", - "newPasswordConfirm": "Confirme a nova palavra-passe", - "newUser": "Novo utilizador", - "password": "Palavra-passe", - "passwordUpdated": "Palavra-passe atualizada!", - "path": "Path", - "perm": { - "create": "Criar ficheiros e pastas", - "delete": "Eliminar ficheiros e pastas", - "download": "Descarregar", - "execute": "Executar comandos", - "modify": "Editar ficheiros", - "rename": "Alterar o nome ou mover ficheiros e pastas", - "share": "Share files (require download permission)" - }, - "permissions": "Permissões", - "permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n", - "profileSettings": "Configurações do utilizador", - "redirectAfterCopyMove": "Redirect to destination after copy/move", - "ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n", - "ruleExample2": "bloqueia o acesso ao ficheiro chamado Caddyfile na raiz.", - "rules": "Regras", - "rulesHelp": "Aqui pode definir um conjunto de regras para permitir ou bloquear o acesso do utilizador a determinados ficheiros ou pastas. Os ficheiros bloqueados não irão aparecer durante a navegação. Suportamos expressões regulares e os caminhos dos ficheiros devem ser relativos à base do utilizador.\n", - "scope": "Base", - "setDateFormat": "Set exact date format", - "settingsUpdated": "Configurações atualizadas!", - "shareDuration": "Share Duration", - "shareManagement": "Share Management", - "shareDeleted": "Share deleted!", - "singleClick": "Use single clicks to open files and directories", - "themes": { - "default": "System default", - "dark": "Dark", - "light": "Light", - "title": "Theme" - }, - "user": "Utilizador", - "userCommands": "Comandos", - "userCommandsHelp": "Uma lista, separada com espaços, de comandos disponíveis para este utilizados. Exemplo:", - "userCreated": "Utilizador criado!", - "userDefaults": "Configurações padrão do utilizador", - "userDeleted": "Utilizador eliminado!", - "userManagement": "Gestão de utilizadores", - "userUpdated": "Utilizador atualizado!", - "username": "Nome de utilizador", - "users": "Utilizadores", - "currentPassword": "Your Current Password" - }, - "sidebar": { - "diskUsed": "{used} of {total} used", - "help": "Ajuda", - "hugoNew": "Hugo New", - "login": "Entrar", - "logout": "Sair", - "myFiles": "Meus ficheiros", - "newFile": "Novo ficheiro", - "newFolder": "Nova pasta", - "preview": "Pré-visualizar", - "settings": "Configurações", - "signup": "Registar", - "siteSettings": "Configurações do site" - }, - "success": { - "linkCopied": "Link copiado!" - }, - "time": { - "days": "Dias", - "hours": "Horas", - "minutes": "Minutos", - "seconds": "Segundos", - "unit": "Unidades de tempo" - } -} diff --git a/frontend/src/i18n/zh-cn.json b/frontend/src/i18n/zh-cn.json index 4d021be3..e03a6d98 100644 --- a/frontend/src/i18n/zh-cn.json +++ b/frontend/src/i18n/zh-cn.json @@ -28,104 +28,104 @@ "publish": "发布", "rename": "重命名", "replace": "替换", - "reportIssue": "报告问题", - "resumeTransfer": "Resume previous transfer", - "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.", + "reportIssue": "反馈问题", + "resumeTransfer": "恢复之前的传输任务", + "resumeTransferTooltip": "跳过所有同名冲突文件;服务器端文件更小则判定为传输中断,保留该文件并继续传输。", "save": "保存", "schedule": "计划", "search": "搜索", "select": "选择", - "selectMultiple": "选择多个", + "selectMultiple": "多选", "share": "分享", - "shell": "激活 Shell", + "shell": "切换终端", "submit": "提交", - "switchView": "切换显示方式", + "switchView": "切换视图", "toggleSidebar": "切换侧边栏", "update": "更新", "upload": "上传", "openFile": "打开文件", - "openDirect": "View raw", - "discardChanges": "放弃更改", + "openDirect": "查看原始内容", + "discardChanges": "放弃", "stopSearch": "停止搜索", "saveChanges": "保存更改", - "editAsText": "以文本形式编辑", - "increaseFontSize": "增大字体大小", - "decreaseFontSize": "减小字体大小", - "overrideAll": "Replace all files in destination folder", - "skipAll": "Skip all conflicting files", - "renameAll": "Rename all files (create a copy)", - "singleDecision": "Decide for each conflicting file" + "editAsText": "以文本模式编辑", + "increaseFontSize": "放大字体", + "decreaseFontSize": "缩小字体", + "overrideAll": "替换目标文件夹中的所有文件", + "skipAll": "跳过所有冲突文件", + "renameAll": "全部重命名(创建副本)", + "singleDecision": "逐个处理冲突文件" }, "download": { "downloadFile": "下载文件", "downloadFolder": "下载文件夹", - "downloadSelected": "下载已选" + "downloadSelected": "下载选中项" }, "upload": { "abortUpload": "你确定要中止吗?" }, "errors": { - "forbidden": "你无权限访问", - "internal": "服务器出了点问题。", - "notFound": "找不到文件。", - "connection": "无法连接到服务器。" + "forbidden": "权限不足,拒绝访问", + "internal": "服务器内部错误", + "notFound": "该位置无法访问。", + "connection": "无法连接服务器" }, "files": { "body": "内容", "closePreview": "关闭预览", "files": "文件", "folders": "文件夹", - "home": "主页", - "lastModified": "最后修改", + "home": "首页", + "lastModified": "修改时间", "loading": "加载中...", - "lonely": "这里没有任何文件...", + "lonely": "暂无任何文件...", "metadata": "元数据", - "multipleSelectionEnabled": "多选模式已开启", + "multipleSelectionEnabled": "已开启多选模式", "name": "名称", "size": "大小", - "sortByLastModified": "按最后修改时间排序", + "sortByLastModified": "按修改时间排序", "sortByName": "按名称排序", "sortBySize": "按大小排序", - "noPreview": "此文件无法预览。", - "csvTooLarge": "CSV文件大到无法预览(>5MB)。请下载查看。", - "csvLoadFailed": "加载 CSV 文件失败。", - "showingRows": "正在显示 {count} 行", + "noPreview": "该文件暂不支持预览", + "csvTooLarge": "CSV 文件过大(>5MB),无法预览,请下载后查看", + "csvLoadFailed": "CSV 文件加载失败", + "showingRows": "当前显示 {count} 行数据", "columnSeparator": "列分隔符", "csvSeparators": { "comma": "逗号 (,)", "semicolon": "分号 (;)", "both": "逗号 (,) 和分号 (;)" }, - "fileEncoding": "File Encoding" + "fileEncoding": "文件编码" }, "help": { - "click": "选择文件或文件夹", + "click": "选中文件或文件夹", "ctrl": { - "click": "选择多个文件或文件夹", + "click": "多选文件或文件夹", "f": "打开搜索框", "s": "保存文件或下载当前文件夹" }, "del": "删除所选的文件/文件夹", "doubleClick": "打开文件/文件夹", - "esc": "清除已选项或关闭提示信息", - "f1": "显示该帮助信息", - "f2": "重命名文件/文件夹", + "esc": "取消选中或关闭提示框", + "f1": "打开帮助", + "f2": "重命名文件", "help": "帮助" }, "login": { - "createAnAccount": "创建用户", - "loginInstead": "已有用户登录", + "createAnAccount": "注册账号", + "loginInstead": "已有账号", "password": "密码", "passwordConfirm": "确认密码", - "passwordsDontMatch": "密码不一致", + "passwordsDontMatch": "两次输入的密码不一致", "signup": "注册", "submit": "登录", "username": "用户名", "usernameTaken": "用户名已经被使用", "wrongCredentials": "用户名或密码错误", - "passwordTooShort": "密码必须至少包含 {min} 个字符", + "passwordTooShort": "密码长度至少为 {min} 位字符", "logout_reasons": { - "inactivity": "由于未活动,您已登出。" + "inactivity": "因长时间未操作,系统已自动登出." } }, "permanent": "永久", @@ -136,58 +136,58 @@ "deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?", "deleteMessageSingle": "你确定要删除这个文件/文件夹吗?", "deleteMessageShare": "你确定要删除这个分享({path})吗?", - "deleteUser": "你确定要删除这个用户吗?", + "deleteUser": "确定要删除该用户吗?", "deleteTitle": "删除文件", "displayName": "名称:", "download": "下载文件", - "downloadMessage": "请选择要下载的压缩格式。", - "error": "出了一点问题...", + "downloadMessage": "请选择要下载的压缩包格式。", + "error": "出现未知错误...", "fileInfo": "文件信息", - "filesSelected": "已选择 {count} 个文件。", - "lastModified": "最后修改", + "filesSelected": "已选中 {count} 个文件", + "lastModified": "修改时间", "move": "移动", "moveMessage": "请选择目标目录:", - "newArchetype": "创建一个基于原型的新帖子。你的文件将会创建在内容文件夹中。", + "newArchetype": "基于模板新建文档,文件将创建在内容目录中。", "newDir": "新建文件夹", "newDirMessage": "请输入新文件夹的名称。", "newFile": "新建文件", "newFileMessage": "请输入新文件的名称。", - "numberDirs": "文件夹数", - "numberFiles": "文件数", + "numberDirs": "文件夹数量", + "numberFiles": "文件数量", "rename": "重命名", - "renameMessage": "请输入新名称,旧名称为:", + "renameMessage": "请输入新名称", "replace": "替换", - "replaceMessage": "你尝试上传的文件中有一个与现有文件的名称存在冲突。是否替换现有的同名文件?\n", + "replaceMessage": "你上传的文件与已有文件重名,是否跳过该文件继续上传,或是替换原有文件?\n", "schedule": "计划", - "scheduleMessage": "请选择发布这篇帖子的日期与时间。", - "show": "点击以显示", + "scheduleMessage": "请选择文档发布的日期和时间", + "show": "显示", "size": "大小", "upload": "上传", "uploadFiles": "正在上传 {files} ...", - "uploadMessage": "选择上传选项。", - "optionalPassword": "密码(选填,不填即无密码)", + "uploadMessage": "请选择上传选项", + "optionalPassword": "密码(选填,留空则无需密码)", "resolution": "分辨率", - "discardEditorChanges": "你确定要放弃所做的更改吗?", - "replaceOrSkip": "Replace or skip files", - "resolveConflict": "Which files do you want to keep?", - "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", - "fastConflictResolve": "The destination folder there are {count} files with same name.", - "uploadingFiles": "Uploading files", - "filesInOrigin": "Files in origin", - "filesInDest": "Files in destination", - "override": "Overwrite", - "skip": "Skip", - "forbiddenError": "Forbidden Error", - "currentPassword": "Your password", - "currentPasswordMessage": "Enter your password to validate this action." + "discardEditorChanges": "是否放弃已修改的内容?", + "replaceOrSkip": "替换或跳过文件", + "resolveConflict": "保留哪些文件?", + "singleConflictResolve": "若同时保留两个版本,复制的文件会自动追加序号。", + "fastConflictResolve": "目标目录内存在 {count} 个同名文件", + "uploadingFiles": "正在上传文件", + "filesInOrigin": "源文件", + "filesInDest": "目标文件", + "override": "覆盖", + "skip": "跳过", + "forbiddenError": "权限错误", + "currentPassword": "当前密码", + "currentPasswordMessage": "请输入当前密码以验证操作" }, "search": { "images": "图像", "music": "音乐", "pdf": "PDF", - "pressToSearch": "按回车以搜索...", + "pressToSearch": "按下回车开始搜索...", "search": "搜索...", - "typeToSearch": "输入以搜索...", + "typeToSearch": "输入内容进行搜索...", "types": "类型", "video": "视频" }, @@ -195,47 +195,47 @@ "aceEditorTheme": "Ace编辑器主题", "admin": "管理员", "administrator": "管理员", - "allowCommands": "执行命令(Shell 命令)", + "allowCommands": "允许执行终端命令", "allowEdit": "编辑、重命名或删除文件/文件夹", "allowNew": "创建新文件和文件夹", "allowPublish": "发布新的帖子与页面", "allowSignup": "允许用户注册", "hideLoginButton": "从公开页面隐藏登录按钮", - "avoidChanges": "(留空以避免更改)", + "avoidChanges": "(留空则不修改)", "branding": "品牌", - "brandingDirectoryPath": "品牌信息文件夹路径", - "brandingHelp": "你可以通过改变实例名称,更换 Logo,加入自定义样式,甚至禁用到 Github 的外部链接来自定义 File Browser 的外观和感觉。\n想获得更多信息,请查看 {0}。", + "brandingDirectoryPath": "品牌资源目录路径", + "brandingHelp": "你可以修改站点名称、更换Logo、添加自定义样式,也可以禁用外部GitHub链接,来自定义本文件管理器界面。\n详情请查看 {0}。", "changePassword": "更改密码", "commandRunner": "命令执行器", - "commandRunnerHelp": "你可以在此设置在下列事件中执行的命令。每行必须写一条命令。可以在命令中使用环境变量 {0} 和 {1},使 {0} 与 {1} 相关联。关于此功能和可用环境变量的更多信息,请阅读 {2}。", + "commandRunnerHelp": "在此设置事件触发命令,单行单条。环境变量 {0}、{1} 可用,{0} 相对 {1} 生效。功能与变量详情请参考 {2}。", "commandsUpdated": "命令已更新!", - "createUserDir": "在添加新用户的同时自动创建用户的主目录", + "createUserDir": "新增用户时自动创建用户主目录", "minimumPasswordLength": "最小密码长度", "tusUploads": "分块上传", - "tusUploadsHelp": "File Browser 支持分块上传,在不佳的网络下也可进行高效、可靠、可续的文件上传", - "tusUploadsChunkSize": "分块上传大小,例如 10MB 或 1GB", + "tusUploadsHelp": "支持文件分片上传,网络不佳时仍可稳定传输,并支持断点续传。", + "tusUploadsChunkSize": "分块大小(例如:10MB、1GB);小于该值的文件将直接上传。", "tusUploadsRetryCount": "分块上传失败时的重试次数", "userHomeBasePath": "用户主目录的路径", "userScopeGenerationPlaceholder": "自动生成目录范围", "createUserHomeDirectory": "创建用户主目录", - "customStylesheet": "自定义样式表(CSS)", - "defaultUserDescription": "这些是新用户的默认设置。", + "customStylesheet": "自定义样式表", + "defaultUserDescription": "以下为新用户的默认配置", "disableExternalLinks": "禁止外部链接(帮助文档除外)", "disableUsedDiskPercentage": "禁用磁盘已用空间展示", "documentation": "帮助文档", "examples": "示例", - "executeOnShell": "在 Shell 中执行", - "executeOnShellDescription": "默认情况下,File Browser 通过直接调用命令的二进制包来执行命令,如果想在 Shell中 执行(如 Bash 或 PowerShell),你可以在这里定义所使用的 Shell 和参数。设置后,你所执行的命令会作为参数追加。本设置对用户命令和事件钩子都生效。", - "globalRules": "这是全局允许与禁止规则。它们作用于所有用户。你可以给每个用户定义单独的特殊规则来覆盖全局规则。", + "executeOnShell": "通过终端执行", + "executeOnShellDescription": "默认直接调用程序执行命令。如需通过终端运行(如Bash、PowerShell 等),可在此配置解释器、参数及选项,执行命令将自动作为参数传入。本设置对用户命令和事件钩子均生效。", + "globalRules": "全局访问规则,对所有用户生效。也可单独为用户设置规则来覆盖全局规则。", "globalSettings": "全局设置", "hideDotfiles": "不显示隐藏文件", "insertPath": "插入路径", "insertRegex": "插入正则表达式", - "instanceName": "实例名称", + "instanceName": "站点名称", "language": "语言", "lockPassword": "禁止用户修改密码", - "newPassword": "你的新密码", - "newPasswordConfirm": "再次输入以确认你的新密码", + "newPassword": "新密码", + "newPasswordConfirm": "再次输入新密码", "newUser": "新建用户", "password": "密码", "passwordUpdated": "密码已更新!", @@ -247,23 +247,23 @@ "execute": "执行命令", "modify": "编辑", "rename": "重命名或移动文件和文件夹", - "share": "Share files (require download permission)" + "share": "分享文件(需开启下载权限)" }, "permissions": "权限", "permissionsHelp": "你可以将该用户设置为管理员或单独选择各项权限。如果你选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n", "profileSettings": "个人设置", "redirectAfterCopyMove": "复制/移动后转到目标位置", - "ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。\n", - "ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。", + "ruleExample1": "禁止用户访问所有目录下以 . 开头的隐藏文件(隐藏文件, 例如: .git, .gitignore)。\n", + "ruleExample2": "禁止用户访问个人根目录下的 Caddyfile 文件。", "rules": "规则", - "rulesHelp": "你可以为该用户制定一组黑名单或白名单式的规则,被屏蔽的文件将不会显示在列表中,用户也无权限访问,支持正则表达式和相对于用户范围的路径。\n", + "rulesHelp": "可为用户配置黑白名单规则,被限制的文件将隐藏且无法访问。支持正则表达式、相对用户目录的路径写法。\n", "scope": "目录范围", "setDateFormat": "显示精确的日期格式", "settingsUpdated": "设置已更新!", "shareDuration": "分享期限", "shareManagement": "分享管理", "shareDeleted": "分享已删除!", - "singleClick": "使用单击来打开文件和文件夹", + "singleClick": "单击打开文件/文件夹", "themes": { "default": "系统默认", "dark": "深色", @@ -271,30 +271,30 @@ "title": "主题" }, "user": "用户", - "userCommands": "用户命令(Shell 命令)", - "userCommandsHelp": "指定该用户可以执行的命令(Shell 命令),用空格分隔。例如:\n", + "userCommands": "可用终端命令", + "userCommandsHelp": "指定该用户可执行的终端命令,多个命令用空格分隔。示例:\n", "userCreated": "用户已创建!", "userDefaults": "用户默认设置", "userDeleted": "用户已删除!", "userManagement": "用户管理", - "userUpdated": "用户已更新!", + "userUpdated": "用户信息已更新!", "username": "用户名", "users": "用户", - "currentPassword": "您当前的密码" + "currentPassword": "当前密码" }, "sidebar": { - "diskUsed": "{used} of {total} used", + "diskUsed": "已使用 {used} / 总容量 {total}", "help": "帮助", "hugoNew": "Hugo 新建", "login": "登录", - "logout": "登出", + "logout": "退出", "myFiles": "我的文件", "newFile": "新建文件", "newFolder": "新建文件夹", "preview": "预览", "settings": "设置", "signup": "注册", - "siteSettings": "网站设置" + "siteSettings": "站点设置" }, "success": { "linkCopied": "链接已复制!" diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index c1592a21..f4864724 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -25,7 +25,7 @@ interface Share { path: string; expire?: any; userID?: number; - token?: string; + hasPassword?: boolean; username?: string; } diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index 37a4e69b..c60b33ab 100644 --- a/frontend/src/utils/__tests__/check-conflict.test.ts +++ b/frontend/src/utils/__tests__/check-conflict.test.ts @@ -4,6 +4,7 @@ import { files as api } from "@/api"; vi.mock("@/api", () => ({ files: { + fetch: vi.fn(), fetchAll: vi.fn(), }, })); @@ -19,8 +20,8 @@ vi.mock("@/stores/layout", () => ({ useLayoutStore: vi.fn() })); vi.mock("@/stores/upload", () => ({ useUploadStore: vi.fn() })); vi.mock("@/utils/url", () => ({ default: {} })); -// A move/copy/drag item carries `name` (raw) and `to` (URL-encoded) but no -// `fullPath` — mirroring what Move.vue / Copy.vue / ListingItem.vue build. +// A move/copy/drag item carries name (raw) and to (URL-encoded) but no +// fullPath - mirroring what Move.vue / Copy.vue / ListingItem.vue build. function moveItem(name: string, dest: string, size = 12) { return { from: `/files/source/${encodeURIComponent(name)}`, @@ -167,29 +168,46 @@ describe("checkConflict", () => { expect(conflicts[0].name).toBe("/target/folder/deep/file.txt"); }); - // Copy/move stats the whole destination, so a same-named directory is a - // conflict in its own right (regression for the directory case of #5957). - it("reports a directory conflict for copy/move (includeDirectories)", async () => { - vi.mocked(api.fetchAll).mockResolvedValue([ - { - path: "/target/folder", - name: "folder", - size: 0, - modified: "2026-06-04T00:00:00Z", - isDir: true, - }, - ]); + // Copy/move only needs the target directory's direct children. A recursive + // walk can make the UI look frozen on large destinations (regression #6005). + it("checks only the direct destination listing for copy/move", async () => { + vi.mocked(api.fetch).mockResolvedValue({ + items: [ + { + path: "/target/file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + { + path: "/target/folder", + name: "folder", + size: 0, + modified: "2026-06-04T00:00:00Z", + isDir: true, + }, + ], + } as Resource); - const items = [{ ...moveItem("folder", "/files/target/", 0), isDir: true }]; + const items = [ + moveItem("file.txt", "/files/target/"), + { ...moveItem("folder", "/files/target/", 0), isDir: true }, + ]; const conflicts = await checkConflict(items, "/files/target/", true); - expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe("/target/folder"); + expect(api.fetch).toHaveBeenCalledWith("/files/target/"); + expect(api.fetchAll).not.toHaveBeenCalled(); + expect(conflicts).toHaveLength(2); + expect(conflicts.map((conflict) => conflict.name)).toEqual([ + "/target/file.txt", + "/target/folder", + ]); }); // Uploads merge into an existing folder, so the directory itself must not be - // reported — only the files inside it can conflict. + // reported - only the files inside it can conflict. it("ignores a directory conflict for uploads (default)", async () => { vi.mocked(api.fetchAll).mockResolvedValue([ { @@ -220,4 +238,52 @@ describe("checkConflict", () => { expect(conflicts).toHaveLength(0); }); + + // Regression for #5980: a FileBrowser server running on Windows returns + // backslash-separated paths from the recursive listing. Without normalizing + // them, the prefix strip and key lookup never match, so the conflict modal is + // skipped and the backend returns a bare 409. + it("detects a conflict for backslash-separated server paths (Windows)", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "\\target\\file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/")], + "/files/target/" + ); + + expect(conflicts).toHaveLength(1); + }); + + it("detects nested conflicts for backslash-separated server paths (Windows)", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "\\target\\folder\\nested file.txt", + name: "nested file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const files = [ + { + name: "nested file.txt", + size: 12, + isDir: false, + fullPath: "folder/nested file.txt", + }, + ]; + + const conflicts = await checkConflict(files, "/files/target/"); + + expect(conflicts).toHaveLength(1); + }); }); diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index c4a9aab4..fec3c2a6 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -21,20 +21,61 @@ function conflictKey(item: UploadEntry): string { return (item.fullPath || item.name).replace(/^\/+/, ""); } +type ServerConflictEntry = { + path: string; + name: string; + size: number; + modified: string; +}; + +async function fetchConflictEntries( + basePath: string, + includeDirectories: boolean +): Promise { + if (!includeDirectories) { + return await api.fetchAll(basePath); + } + + const destination = await api.fetch(basePath); + return destination.items ?? []; +} + +function conflictPath(entry: ServerConflictEntry): string { + return entry.path.replace(/\\/g, "/"); +} + +function buildConflictMap( + serverEntries: ServerConflictEntry[], + basePath: string, + includeDirectories: boolean +): Map { + const serverMap = new Map(); + const normBase = removePrefix(basePath).replace(/\/+$/, ""); + for (const entry of serverEntries) { + // A Windows server may return OS-native backslash separators; normalize to + // forward slashes so the prefix strip and key lookup line up. + const path = conflictPath(entry); + const key = includeDirectories + ? entry.name + : path.startsWith(normBase) + ? path.slice(normBase.length) + : path; + serverMap.set(key.replace(/^\/+/, ""), entry); + } + + return serverMap; +} + /** * Return the entries from `files` that already exist under `basePath` on the * server, so the caller can prompt the user to overwrite/rename/skip. * - * The whole destination tree is fetched once and indexed by path relative to - * the destination, then every entry is looked up directly — no need to mirror - * the upload's folder structure. - * * Directory handling differs by action, hence `includeDirectories`: - * - Upload (false): an existing folder is silently merged, so only the - * individual files inside it can conflict. - * - Copy/move (true): the server stats the destination and rejects it whole if - * a same-named entry exists, so the directory itself is a conflict. The list - * only holds the top-level items being moved, so each is reported once. + * - Upload (false): the destination tree is fetched recursively so nested + * file uploads can be checked; existing folders are silently merged. + * - Copy/move (true): only the destination directory itself is fetched. These + * operations move flat top-level selections, so a same-named direct child is + * the only preflight conflict the backend will reject. * * @param files - flat upload list to check * @param basePath - server destination path (e.g. "/files/uploads/") @@ -47,26 +88,19 @@ export async function checkConflict( ): Promise { if (files.length === 0) return []; - let serverEntries: RecursiveEntry[]; + let serverEntries: ServerConflictEntry[]; try { - // Single API call: fetch the entire server tree under basePath. - serverEntries = await api.fetchAll(basePath); + serverEntries = await fetchConflictEntries(basePath, includeDirectories); } catch { // The destination doesn't exist yet, so nothing can conflict. return []; } - // The server returns paths absolute within the user's scope - // (e.g. "/uploads/sub/file.txt"). Strip the basePath prefix so the keys line - // up with each entry's conflictKey, which is relative to the destination. - const normBase = removePrefix(basePath).replace(/\/+$/, ""); - const serverMap = new Map(); - for (const entry of serverEntries) { - const rel = entry.path.startsWith(normBase) - ? entry.path.slice(normBase.length) - : entry.path; - serverMap.set(rel.replace(/^\/+/, ""), entry); - } + const serverMap = buildConflictMap( + serverEntries, + basePath, + includeDirectories + ); const conflicts: ConflictingResource[] = []; files.forEach((file, index) => { @@ -77,7 +111,7 @@ export async function checkConflict( conflicts.push({ index, - name: server.path, + name: conflictPath(server), origin: { lastModified: file.file?.lastModified, size: file.size }, dest: { lastModified: server.modified, size: server.size }, checked: ["origin"], diff --git a/go.mod b/go.mod index b333522e..042a6a32 100644 --- a/go.mod +++ b/go.mod @@ -4,30 +4,30 @@ go 1.25.0 require ( github.com/asdine/storm/v3 v3.2.1 - github.com/asticode/go-astisub v0.40.0 + github.com/asticode/go-astisub v0.41.0 github.com/disintegration/imaging v1.6.2 github.com/dsoprea/go-exif/v3 v3.0.1 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 - github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jellydator/ttlcache/v3 v3.4.1 github.com/maruel/natural v1.3.0 github.com/marusama/semaphore/v2 v2.5.0 github.com/mholt/archives v0.1.5 github.com/mitchellh/go-homedir v1.1.0 - github.com/redis/go-redis/v9 v9.20.1 + github.com/redis/go-redis/v9 v9.21.0 github.com/samber/lo v1.53.0 - github.com/shirou/gopsutil/v4 v4.26.5 + github.com/shirou/gopsutil/v4 v4.26.6 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce - go.etcd.io/bbolt v1.4.3 + go.etcd.io/bbolt v1.5.0 golang.org/x/crypto v0.53.0 - golang.org/x/image v0.42.0 + golang.org/x/image v0.43.0 golang.org/x/text v0.38.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -35,7 +35,7 @@ require ( require ( github.com/STARRY-S/zip v0.2.3 // indirect - github.com/andybalholm/brotli v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/asticode/go-astikit v0.59.0 // indirect github.com/asticode/go-astits v1.15.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect @@ -52,16 +52,16 @@ require ( github.com/go-errors/errors v1.5.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 // indirect + github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.1.1 // indirect - github.com/nwaples/rardecode/v2 v2.2.3 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/nwaples/rardecode/v2 v2.2.5 // indirect + github.com/pelletier/go-toml/v2 v2.4.2 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/go.sum b/go.sum index c228b345..76e51e7e 100644 --- a/go.sum +++ b/go.sum @@ -4,16 +4,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac= github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0= github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.59.0 h1:tjbwDym+MTSxqkAhJoHRZmHMXK6Jv4vGx+97FptKH6k= github.com/asticode/go-astikit v0.59.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astisub v0.40.0 h1:Z8tAXHngjkh/4L9zqt49ru9UdpDTqBIe+80xexKM3/I= -github.com/asticode/go-astisub v0.40.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= +github.com/asticode/go-astisub v0.41.0 h1:XFPIreVnpi9nPNVRmTdRuq4fr9XzGhgCRwpAaccnShM= +github.com/asticode/go-astisub v0.41.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2/0J4= github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= @@ -82,8 +82,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= -github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 h1:UlucSQUu9SZdDRlMWv5N/T3Rig9gv615vWvHFKrXHWA= -github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8= +github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 h1:KeIaDS/+VEy/bhDYjG3Z78dOyLAU4HXcVxmd0WYHJTE= +github.com/golang/geo v0.0.0-20260625163123-7c0e84413537/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -101,13 +101,13 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= @@ -134,10 +134,10 @@ github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM= github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A= -github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY= +github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= +github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -146,8 +146,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= -github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -156,8 +156,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= -github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -201,8 +201,8 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -216,8 +216,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= -golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/http/auth.go b/http/auth.go index 4381e86c..b96819b8 100644 --- a/http/auth.go +++ b/http/auth.go @@ -102,7 +102,7 @@ func withUser(fn handleFunc) handleFunc { w.Header().Add("X-Renew-Token", "true") } - d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID) + d.user, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, tk.User.ID) if err != nil { return http.StatusInternalServerError, err } @@ -201,6 +201,22 @@ var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, return http.StatusInternalServerError, err } user.Scope = userHome + + // When home directories are created from the username, distinct usernames + // can normalize to the same scope (cleanUsername is many-to-one), which would + // silently hand the new user another user's home directory. Reject the signup + // if the derived scope is already taken. When CreateUserDir is off, all + // signups intentionally share the configured default scope, so this check + // does not apply. + if d.settings.CreateUserDir { + switch _, err := d.store.Users.GetByScope(user.Scope); { + case err == nil: + return http.StatusConflict, fberrors.ErrExist + case !errors.Is(err, fberrors.ErrNotExist): + return http.StatusInternalServerError, err + } + } + log.Printf("new user: %s, home dir: [%s].", user.Username, userHome) err = d.store.Users.Save(user) diff --git a/http/auth_test.go b/http/auth_test.go new file mode 100644 index 00000000..bacff284 --- /dev/null +++ b/http/auth_test.go @@ -0,0 +1,71 @@ +package fbhttp + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/asdine/storm/v3" + + "github.com/filebrowser/filebrowser/v2/settings" + "github.com/filebrowser/filebrowser/v2/storage/bolt" +) + +// Regression for the username-normalization home-directory collision +// (GHSA-7rc3-g7h6-22m7): with Signup and CreateUserDir enabled, two distinct +// usernames that cleanUsername() normalizes to the same directory must not be +// handed the same home directory. The second registration is rejected. +func TestSignupRejectsCollidingNormalizedScope(t *testing.T) { + root := t.TempDir() + + 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.Settings.Save(&settings.Settings{ + Key: []byte("test-signing-key"), + Signup: true, + CreateUserDir: true, + UserHomeBasePath: "/users", + MinimumPasswordLength: 1, + }); err != nil { + t.Fatalf("failed to save settings: %v", err) + } + + server := &settings.Server{Root: root} + + signup := func(username string) *httptest.ResponseRecorder { + body := `{"username":"` + username + `","password":"CollidePw12345!"}` + req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(body)) + rec := httptest.NewRecorder() + handle(signupHandler, "", st, server).ServeHTTP(rec, req) + return rec + } + + // Victim registers first and gets /users/teamone-x. + if rec := signup("teamone-x"); rec.Code != http.StatusOK { + t.Fatalf("first signup: expected 200, got %d body=%q", rec.Code, rec.Body.String()) + } + + // Attacker picks a distinct username that normalizes to the same scope. + if rec := signup("teamone/x"); rec.Code != http.StatusConflict { + t.Fatalf("VULNERABLE: colliding signup expected 409, got %d body=%q", rec.Code, rec.Body.String()) + } + + // The shared scope must still be owned solely by the first user. + owner, err := st.Users.GetByScope("/users/teamone-x") + if err != nil { + t.Fatalf("expected first user to own the scope: %v", err) + } + if owner.Username != "teamone-x" { + t.Fatalf("scope owner = %q, want teamone-x", owner.Username) + } +} diff --git a/http/public.go b/http/public.go index a882f2f7..211f8a87 100644 --- a/http/public.go +++ b/http/public.go @@ -27,7 +27,7 @@ var withHashFile = func(fn handleFunc) handleFunc { return status, err } - user, err := d.store.Users.Get(d.server.Root, link.UserID) + user, err := d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID) if err != nil { return errToStatus(err), err } @@ -63,11 +63,12 @@ var withHashFile = func(fn handleFunc) handleFunc { filePath = ifPath } - // set fs root to the shared file/folder. ScopedFs (not a bare - // BasePathFs) so the share is also symlink-confined: a link inside the - // shared subtree that points elsewhere in the owner's scope — outside - // the share — must not be followed. - d.user.Fs = files.NewScopedFs(d.user.Fs, basePath) + // set fs root to the shared file/folder. Unless external symlinks are + // explicitly allowed, this is a ScopedFs (not a bare BasePathFs) so the + // share is also symlink-confined: a link inside the shared subtree that + // points elsewhere in the owner's scope — outside the share — must not be + // followed. + d.user.Fs = files.NewFs(d.user.Fs, basePath, d.server.FollowExternalSymlinks) // the filesystem is now rebased onto basePath, so paths handed to the // rule checker are relative to it. Resolve them back to the user's diff --git a/http/public_symlink_test.go b/http/public_symlink_test.go index fa631c82..0343046e 100644 --- a/http/public_symlink_test.go +++ b/http/public_symlink_test.go @@ -126,6 +126,86 @@ func TestPublicShareSymlinkListingOmitsEscapingLink(t *testing.T) { } } +// 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. diff --git a/http/public_test.go b/http/public_test.go index 0b0b7677..c728102d 100644 --- a/http/public_test.go +++ b/http/public_test.go @@ -269,16 +269,24 @@ func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http type customFSUser struct { users.Store fs afero.Fs + // followExternal mirrors Server.FollowExternalSymlinks: when set, the + // provided fs is used as-is (a bare BasePathFs that follows symlinks); + // otherwise it is wrapped in a symlink-confining ScopedFs. + followExternal bool } -func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) { - user, err := cu.Store.Get(baseScope, id) +func (cu *customFSUser) Get(baseScope string, followExternalSymlinks bool, id interface{}) (*users.User, error) { + user, err := cu.Store.Get(baseScope, followExternalSymlinks, id) if err != nil { return nil, err } - // Mirror production (users.User init), where a user's filesystem is always a - // scoped, symlink-confining ScopedFs rather than a bare afero.Fs. - user.Fs = files.NewScopedFs(cu.fs, "/") + // Inject a filesystem rooted at the test's temp scope, standing in for the + // one users.User.Clean would build in production. + if cu.followExternal { + user.Fs = cu.fs + } else { + user.Fs = files.NewScopedFs(cu.fs, "/") + } return user, nil } diff --git a/http/raw.go b/http/raw.go index 860d6aa7..8f8c5751 100644 --- a/http/raw.go +++ b/http/raw.go @@ -2,6 +2,7 @@ package fbhttp import ( "errors" + "fmt" "io/fs" "log" "net/http" @@ -125,12 +126,19 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) { nameInArchive := strings.TrimPrefix(path, commonPath) nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator)) nameInArchive = filepath.ToSlash(nameInArchive) - // filepath.ToSlash only rewrites the host separator, so on a Linux - // host a stored backslash survives and is emitted verbatim into the - // archive. Windows extractors then treat "\" as a path separator, - // allowing the entry to escape the extraction directory (zip-slip). - // Strip Windows separators regardless of host OS. - nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/") + // A backslash is a legal filename character on POSIX hosts, so it can + // reach here verbatim. Rewriting it to the path separator "/" would + // manufacture a traversal sequence (e.g. "..\..\x" -> "../../x") that + // escapes the extraction directory on the victim's machine, while + // leaving it as "\" lets Windows extractors treat it as a separator. + // Neutralize it to an inert character instead of turning it into one. + nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "_") + + // Defense in depth: never emit an archive entry whose path escapes the + // archive root, regardless of how the name was produced. + if cleaned := gopath.Clean("/" + nameInArchive); cleaned != "/"+nameInArchive { + return nil, fmt.Errorf("refusing unsafe archive entry name: %q", nameInArchive) + } archiveFiles = append(archiveFiles, archives.FileInfo{ FileInfo: info, diff --git a/http/raw_test.go b/http/raw_test.go index c35334f6..7fe3e916 100644 --- a/http/raw_test.go +++ b/http/raw_test.go @@ -1,14 +1,75 @@ package fbhttp import ( + "archive/zip" + "bytes" "net/http" "net/http/httptest" "net/url" + "os" + "path" + "path/filepath" + "strings" "testing" "github.com/filebrowser/filebrowser/v2/files" + "github.com/filebrowser/filebrowser/v2/settings" + "github.com/filebrowser/filebrowser/v2/users" ) +// Regression for the archive backslash-to-slash zip-slip (GHSA-83xp-526h-j3ww): +// a single in-scope file whose name contains backslashes is a legal POSIX +// filename, not a traversal. The archive builder must never rewrite "\" into the +// path separator "/", which would manufacture an entry like "../../evil.sh" that +// escapes the extraction directory on the downloader's machine. +func TestRawArchiveDoesNotManufactureTraversal(t *testing.T) { + root := t.TempDir() + userScope := filepath.Join(root, "user") + if err := os.MkdirAll(filepath.Join(userScope, "ziptest"), 0o755); err != nil { + t.Fatal(err) + } + + // One legal Linux/macOS filename whose bytes include backslashes. It does not + // traverse on the server; it only becomes "../../evil.sh" if the builder + // turns "\" into "/". + planted := filepath.Join(userScope, "ziptest", "..\\..\\evil.sh") + if err := os.WriteFile(planted, []byte("#!/bin/sh\necho PWNED"), 0o644); err != nil { + t.Skipf("cannot create backslash-named file: %v", err) + } + + key := []byte("test-signing-key") + perm := users.Permissions{Download: true} + st := scopedUserStorage(t, userScope, perm, key) + signed := signToken(t, perm, key) + + req, _ := http.NewRequest(http.MethodGet, "/ziptest?algo=zip", http.NoBody) + req.Header.Set("X-Auth", signed) + rec := httptest.NewRecorder() + handle(rawHandler, "", st, &settings.Server{}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String()) + } + + zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len())) + if err != nil { + t.Fatalf("failed to read zip: %v", err) + } + if len(zr.File) == 0 { + t.Fatal("archive has no entries") + } + + for _, f := range zr.File { + // The entry must be a normalized, root-relative path: no ".." segments + // and no leading "/". Note a name may legitimately contain ".." as part of + // a single filename (e.g. ".._.._evil.sh"), which Clean leaves untouched — + // so compare against the normalized form rather than searching for "..". + if strings.HasPrefix(f.Name, "/") || path.Clean("/"+f.Name) != "/"+f.Name { + t.Errorf("VULNERABLE: archive entry escapes root: %q", f.Name) + } + } +} + func TestSetContentDisposition(t *testing.T) { t.Parallel() diff --git a/http/resource.go b/http/resource.go index 728bad73..d8f5cad9 100644 --- a/http/resource.go +++ b/http/resource.go @@ -130,7 +130,9 @@ func resourcePostHandler(fileCache FileCache) handleFunc { // Directories creation on POST. if strings.HasSuffix(r.URL.Path, "/") { - err := d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode) + err := d.RunHook(func() error { + return d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode) + }, "upload", r.URL.Path, "", d.user) return errToStatus(err), err } @@ -424,7 +426,10 @@ var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.R } entries = append(entries, RecursiveEntry{ - Path: fPath, + // afero.Walk joins paths with the OS separator, so on Windows fPath + // uses backslashes. The web API contract is forward slashes, so + // normalize it here (mirrors search/search.go). + Path: filepath.ToSlash(fPath), Name: info.Name(), Size: info.Size(), ModTime: info.ModTime(), diff --git a/http/resource_test.go b/http/resource_test.go index 7081206f..5a29518d 100644 --- a/http/resource_test.go +++ b/http/resource_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/filebrowser/filebrowser/v2/diskcache" "github.com/filebrowser/filebrowser/v2/settings" + "github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/users" ) @@ -115,3 +117,143 @@ func signToken(t *testing.T, perm users.Permissions, key []byte) string { } return signed } + +// scopedUserStorage returns a storage whose single user (ID 1) is scoped to +// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring +// production. Used by the symlink scope-escape regression tests below. +func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage { + t.Helper() + 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.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil { + t.Fatalf("failed to save user: %v", err) + } + if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil { + t.Fatalf("failed to save settings: %v", err) + } + st.Users = &customFSUser{ + Store: st.Users, + fs: afero.NewBasePathFs(afero.NewOsFs(), userScope), + } + return st +} + +// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 / +// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is +// outside the scope must not dereference the link to create the out-of-scope +// file. +func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) { + root := t.TempDir() + userScope := filepath.Join(root, "user") + outside := filepath.Join(root, "outside") + for _, d := range []string{userScope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + // A dangling symlink inside the scope pointing at a not-yet-existing file + // outside it (planted out-of-band, per the advisory preconditions). + outsideTarget := filepath.Join(outside, "created.txt") + if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + key := []byte("test-signing-key") + perm := users.Permissions{Create: true, Modify: true} + st := scopedUserStorage(t, userScope, perm, key) + signed := signToken(t, perm, key) + + req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside")) + req.Header.Set("X-Auth", signed) + rec := httptest.NewRecorder() + handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req) + + if _, statErr := os.Stat(outsideTarget); statErr == nil { + data, _ := os.ReadFile(outsideTarget) + t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data)) + } + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String()) + } +} + +// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp / +// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping +// symlinked directory must not delete the out-of-scope target through the +// failed-upload cleanup RemoveAll. +func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) { + root := t.TempDir() + userScope := filepath.Join(root, "user") + outside := filepath.Join(root, "outside") + for _, d := range []string{userScope, outside} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + victim := filepath.Join(outside, "victim.txt") + if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + // An escaping directory symlink inside the scope (planted out-of-band). + if err := os.Symlink(outside, filepath.Join(userScope, "link")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + key := []byte("test-signing-key") + // Create-only: Perm.Delete is deliberately false — the bug must not need it. + perm := users.Permissions{Create: true} + st := scopedUserStorage(t, userScope, perm, key) + signed := signToken(t, perm, key) + + req, _ := http.NewRequest(http.MethodPost, "/link/victim.txt", strings.NewReader("x")) + req.Header.Set("X-Auth", signed) + rec := httptest.NewRecorder() + handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req) + + if _, statErr := os.Stat(victim); statErr != nil { + t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr) + } +} + +func TestResourcePostRunsUploadHooksForDirectories(t *testing.T) { + root := t.TempDir() + userScope := filepath.Join(root, "user") + if err := os.MkdirAll(userScope, 0o755); err != nil { + t.Fatal(err) + } + + key := []byte("test-signing-key") + perm := users.Permissions{Create: true} + st := scopedUserStorage(t, userScope, perm, key) + if err := st.Settings.Save(&settings.Settings{ + Key: key, + Commands: map[string][]string{ + "after_upload": {"filebrowser-hook-command-that-does-not-exist"}, + }, + }); err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest(http.MethodPost, "/created/", http.NoBody) + req.Header.Set("X-Auth", signToken(t, perm, key)) + rec := httptest.NewRecorder() + handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{EnableExec: true}).ServeHTTP(rec, req) + + // A missing after_upload command makes the request fail only if the hook ran. + // It avoids a platform-specific helper executable while still exercising the + // same path the web UI uses for directory uploads. + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected directory upload hook failure to return 500, got %d body=%q", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(userScope, "created")); err != nil { + t.Fatalf("expected directory to be created before its after hook, got %v", err) + } +} diff --git a/http/share.go b/http/share.go index 7cd08faf..8ed4feb4 100644 --- a/http/share.go +++ b/http/share.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "sort" "strconv" "strings" @@ -14,9 +15,40 @@ import ( fberrors "github.com/filebrowser/filebrowser/v2/errors" "github.com/filebrowser/filebrowser/v2/share" + "github.com/filebrowser/filebrowser/v2/users" "golang.org/x/crypto/bcrypt" ) +// shareResponse is the client-facing representation of a share. It deliberately +// omits the server-side secrets of share.Link — the bcrypt PasswordHash (which +// would be crackable offline) and the bypass Token — exposing only whether the +// share is password-protected via HasPassword. +type shareResponse struct { + Hash string `json:"hash"` + Path string `json:"path"` + UserID uint `json:"userID"` + Expire int64 `json:"expire"` + HasPassword bool `json:"hasPassword"` +} + +func toShareResponse(l *share.Link) *shareResponse { + return &shareResponse{ + Hash: l.Hash, + Path: l.Path, + UserID: l.UserID, + Expire: l.Expire, + HasPassword: l.PasswordHash != "", + } +} + +func toShareResponses(links []*share.Link) []*shareResponse { + res := make([]*shareResponse, 0, len(links)) + for _, l := range links { + res = append(res, toShareResponse(l)) + } + return res +} + func withPermShare(fn handleFunc) handleFunc { return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { if !d.user.Perm.Share || !d.user.Perm.Download { @@ -38,7 +70,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request s, err = d.store.Share.FindByUserID(d.user.ID) } if errors.Is(err, fberrors.ErrNotExist) { - return renderJSON(w, r, []*share.Link{}) + return renderJSON(w, r, []*shareResponse{}) } if err != nil { @@ -52,7 +84,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request return s[i].Expire < s[j].Expire }) - return renderJSON(w, r, s) + return renderJSON(w, r, toShareResponses(s)) }) var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { @@ -61,21 +93,47 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request err error ) if d.user.Perm.Admin { - s, err = d.store.Share.GetsByPath(r.URL.Path) + s, err = getSharesForAdminPath(d, r.URL.Path) } else { s, err = d.store.Share.Gets(r.URL.Path, d.user.ID) } if errors.Is(err, fberrors.ErrNotExist) { - return renderJSON(w, r, []*share.Link{}) + return renderJSON(w, r, []*shareResponse{}) } if err != nil { return http.StatusInternalServerError, err } - return renderJSON(w, r, s) + return renderJSON(w, r, toShareResponses(s)) }) +func getSharesForAdminPath(d *data, path string) ([]*share.Link, error) { + links, err := d.store.Share.All() + if err != nil { + return nil, err + } + + adminPath := filepath.Clean(d.user.FullPath(path)) + owners := make(map[uint]*users.User) + filtered := make([]*share.Link, 0, len(links)) + for _, link := range links { + owner, ok := owners[link.UserID] + if !ok { + owner, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID) + if err != nil && !errors.Is(err, fberrors.ErrNotExist) { + return nil, err + } + owners[link.UserID] = owner // owner is nil on ErrNotExist + } + if owner != nil && filepath.Clean(owner.FullPath(link.Path)) == adminPath { + filtered = append(filtered, link) + } + } + + return filtered, nil +} + var shareDeleteHandler = withPermShare(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) { hash := strings.TrimSuffix(r.URL.Path, "/") hash = strings.TrimPrefix(hash, "/") @@ -176,7 +234,7 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request return http.StatusInternalServerError, err } - return renderJSON(w, r, s) + return renderJSON(w, r, toShareResponse(s)) }) func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) { diff --git a/http/share_test.go b/http/share_test.go new file mode 100644 index 00000000..c03a9393 --- /dev/null +++ b/http/share_test.go @@ -0,0 +1,164 @@ +package fbhttp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/asdine/storm/v3" + "github.com/golang-jwt/jwt/v5" + + "github.com/filebrowser/filebrowser/v2/settings" + "github.com/filebrowser/filebrowser/v2/share" + "github.com/filebrowser/filebrowser/v2/storage/bolt" + "github.com/filebrowser/filebrowser/v2/users" +) + +func TestAdminShareGetsHandlerMatchesOwnerScope(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ownerScope := filepath.Join(root, "owner") + if err := os.MkdirAll(ownerScope, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ownerScope, "file.txt"), []byte("shared"), 0o600); err != nil { + t.Fatal(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) + } + + owner := &users.User{ + Username: "owner", + Password: "pw", + Scope: "/owner", + Perm: users.Permissions{Share: true, Download: true}, + } + if err := st.Users.Save(owner); err != nil { + t.Fatalf("failed to save owner: %v", err) + } + + adminPerm := users.Permissions{Admin: true, Share: true, Download: true} + admin := &users.User{ + Username: "admin", + Password: "pw", + Scope: "/", + Perm: adminPerm, + } + if err := st.Users.Save(admin); err != nil { + t.Fatalf("failed to save admin: %v", err) + } + + if err := st.Share.Save(&share.Link{Hash: "h", UserID: owner.ID, Path: "/file.txt"}); err != nil { + t.Fatalf("failed to save share: %v", err) + } + key := []byte("test-signing-key") + if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil { + t.Fatalf("failed to save settings: %v", err) + } + + req, err := http.NewRequest(http.MethodGet, "/owner/file.txt", http.NoBody) + if err != nil { + t.Fatalf("failed to construct request: %v", err) + } + req.Header.Set("X-Auth", signShareTestToken(t, admin.ID, admin.Username, adminPerm, key)) + + rec := httptest.NewRecorder() + handle(shareGetsHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var links []*share.Link + if err := json.Unmarshal(rec.Body.Bytes(), &links); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(links) != 1 || links[0].Hash != "h" { + t.Fatalf("expected admin to see owner share h, got %#v", links) + } +} + +// Regression for the share secret exposure (GHSA-833g-cqhp-h72j): the share API +// must not serialize the bcrypt password hash or the bypass token, while still +// persisting them server-side so password-protected shares keep working. +func TestSharePostHandlerDoesNotLeakSecrets(t *testing.T) { + root := t.TempDir() + userScope := filepath.Join(root, "user") + if err := os.MkdirAll(userScope, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userScope, "file.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + key := []byte("test-signing-key") + perm := users.Permissions{Share: true, Download: true} + st := scopedUserStorage(t, userScope, perm, key) + signed := signToken(t, perm, key) + + body := `{"password":"ShareSecret123!","expires":"24","unit":"hours"}` + req, _ := http.NewRequest(http.MethodPost, "/file.txt", strings.NewReader(body)) + req.Header.Set("X-Auth", signed) + rec := httptest.NewRecorder() + handle(sharePostHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := resp["password_hash"]; ok { + t.Errorf("VULNERABLE: response leaks password_hash: %s", rec.Body.String()) + } + if _, ok := resp["token"]; ok { + t.Errorf("VULNERABLE: response leaks token: %s", rec.Body.String()) + } + if resp["hasPassword"] != true { + t.Errorf("expected hasPassword=true, got %v", resp["hasPassword"]) + } + + // The secrets must still be persisted server-side (storm uses the JSON codec, + // so the storage struct's tags must keep emitting them). + stored, err := st.Share.GetByHash(resp["hash"].(string)) + if err != nil { + t.Fatalf("share not stored: %v", err) + } + if stored.PasswordHash == "" || stored.Token == "" { + t.Fatalf("server-side secrets not persisted: hash=%q token=%q", stored.PasswordHash, stored.Token) + } +} + +func signShareTestToken(t *testing.T, id uint, username string, perm users.Permissions, key []byte) string { + t.Helper() + + claims := &authToken{ + User: userInfo{ID: id, Username: username, Perm: perm}, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key) + if err != nil { + t.Fatalf("failed to sign token: %v", err) + } + return signed +} diff --git a/http/subtitle.go b/http/subtitle.go index 07c2dcfc..24b49256 100644 --- a/http/subtitle.go +++ b/http/subtitle.go @@ -2,7 +2,9 @@ package fbhttp import ( "bytes" + "io" "net/http" + "regexp" "strings" "github.com/asticode/go-astisub" @@ -10,6 +12,8 @@ import ( "github.com/filebrowser/filebrowser/v2/files" ) +var srtLineBreakTag = regexp.MustCompile(`(?i)]*)?\s*/?>`) + var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { if !d.user.Perm.Download { return http.StatusAccepted, nil @@ -49,7 +53,11 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil // load subtitle for conversion to vtt var sub *astisub.Subtitles if strings.HasSuffix(file.Name, ".srt") { - sub, err = astisub.ReadFromSRT(fd) + content, readErr := io.ReadAll(fd) + if readErr != nil { + return http.StatusInternalServerError, readErr + } + sub, err = astisub.ReadFromSRT(bytes.NewReader(normalizeSRTLineBreaks(content))) } else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") { sub, err = astisub.ReadFromSSA(fd) } @@ -78,3 +86,7 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes())) return 0, nil } + +func normalizeSRTLineBreaks(content []byte) []byte { + return srtLineBreakTag.ReplaceAll(content, []byte("\n")) +} diff --git a/http/subtitle_test.go b/http/subtitle_test.go new file mode 100644 index 00000000..2520d453 --- /dev/null +++ b/http/subtitle_test.go @@ -0,0 +1,62 @@ +package fbhttp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/afero" + + "github.com/filebrowser/filebrowser/v2/files" +) + +func TestNormalizeSRTLineBreaks(t *testing.T) { + input := []byte("first
second
third
fourth
fifth") + got := string(normalizeSRTLineBreaks(input)) + want := "first\nsecond\nthird\nfourth\nfifth" + if got != want { + t.Fatalf("normalizeSRTLineBreaks() = %q, want %q", got, want) + } +} + +func TestSubtitleFileHandlerConvertsSRTBreakTags(t *testing.T) { + fs := afero.NewMemMapFs() + const path = "/sample.srt" + const content = "1\n" + + "00:00:01,000 --> 00:00:02,000\n" + + "First
Second
Third
Fourth\n\n" + + if err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write subtitle: %v", err) + } + info, err := fs.Stat(path) + if err != nil { + t.Fatalf("failed to stat subtitle: %v", err) + } + + file := &files.FileInfo{ + Fs: fs, + Path: path, + Name: "sample.srt", + ModTime: info.ModTime(), + } + req := httptest.NewRequest(http.MethodGet, "/api/subtitle/sample.srt?inline=true", http.NoBody) + rec := httptest.NewRecorder() + + status, err := subtitleFileHandler(rec, req, file) + if err != nil { + t.Fatalf("subtitleFileHandler returned error: %v", err) + } + if status != 0 { + t.Fatalf("subtitleFileHandler status = %d, want 0", status) + } + + body := rec.Body.String() + if strings.Contains(body, "FirstSecond") { + t.Fatalf("WebVTT output collapsed SRT
tags: %q", body) + } + if !strings.Contains(body, "First\nSecond\nThird\nFourth") { + t.Fatalf("WebVTT output = %q, want converted SRT
tags as line breaks", body) + } +} diff --git a/http/users.go b/http/users.go index e61ab00b..13092e3e 100644 --- a/http/users.go +++ b/http/users.go @@ -71,7 +71,7 @@ func withSelfOrAdmin(fn handleFunc) handleFunc { } var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { - users, err := d.store.Users.Gets(d.server.Root) + users, err := d.store.Users.Gets(d.server.Root, d.server.FollowExternalSymlinks) if err != nil { return http.StatusInternalServerError, err } @@ -88,7 +88,7 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d * }) var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { - u, err := d.store.Users.Get(d.server.Root, d.raw.(uint)) + u, err := d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint)) if errors.Is(err, fberrors.ErrNotExist) { return http.StatusNotFound, err } @@ -228,7 +228,7 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request } } else { var suser *users.User - suser, err = d.store.Users.Get(d.server.Root, d.raw.(uint)) + suser, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint)) if err != nil { return http.StatusInternalServerError, err } diff --git a/settings/settings.go b/settings/settings.go index d71be16a..44297fd5 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -47,21 +47,22 @@ func (s *Settings) GetRules() []rules.Rule { // Server specific settings. type Server struct { - Root string `json:"root"` - BaseURL string `json:"baseURL"` - Socket string `json:"socket"` - TLSKey string `json:"tlsKey"` - TLSCert string `json:"tlsCert"` - Port string `json:"port"` - Address string `json:"address"` - Log string `json:"log"` - EnableThumbnails bool `json:"enableThumbnails"` - ResizePreview bool `json:"resizePreview"` - EnableExec bool `json:"enableExec"` - TypeDetectionByHeader bool `json:"typeDetectionByHeader"` - ImageResolutionCal bool `json:"imageResolutionCalculation"` - AuthHook string `json:"authHook"` - TokenExpirationTime string `json:"tokenExpirationTime"` + Root string `json:"root"` + BaseURL string `json:"baseURL"` + Socket string `json:"socket"` + TLSKey string `json:"tlsKey"` + TLSCert string `json:"tlsCert"` + Port string `json:"port"` + Address string `json:"address"` + Log string `json:"log"` + EnableThumbnails bool `json:"enableThumbnails"` + ResizePreview bool `json:"resizePreview"` + EnableExec bool `json:"enableExec"` + TypeDetectionByHeader bool `json:"typeDetectionByHeader"` + ImageResolutionCal bool `json:"imageResolutionCalculation"` + AuthHook string `json:"authHook"` + TokenExpirationTime string `json:"tokenExpirationTime"` + FollowExternalSymlinks bool `json:"followExternalSymlinks"` } // Clean cleans any variables that might need cleaning. diff --git a/share/storage.go b/share/storage.go index 684ab018..3b73ef35 100644 --- a/share/storage.go +++ b/share/storage.go @@ -110,23 +110,6 @@ func (s *Storage) Gets(path string, id uint) ([]*Link, error) { return links, nil } -// GetsByPath returns all non-expired links for a path. -func (s *Storage) GetsByPath(path string) ([]*Link, error) { - links, err := s.All() - if err != nil { - return nil, err - } - - filtered := make([]*Link, 0, len(links)) - for _, link := range links { - if link.Path == path { - filtered = append(filtered, link) - } - } - - return filtered, nil -} - // Save wraps a StorageBackend.Save func (s *Storage) Save(l *Link) error { return s.back.Save(l) diff --git a/share/storage_test.go b/share/storage_test.go deleted file mode 100644 index dd78fe71..00000000 --- a/share/storage_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package share - -import ( - "reflect" - "testing" -) - -type fakeBackend struct { - links []*Link -} - -func (f fakeBackend) All() ([]*Link, error) { - return f.links, nil -} - -func (f fakeBackend) FindByUserID(id uint) ([]*Link, error) { - var links []*Link - for _, link := range f.links { - if link.UserID == id { - links = append(links, link) - } - } - return links, nil -} - -func (f fakeBackend) GetByHash(hash string) (*Link, error) { - for _, link := range f.links { - if link.Hash == hash { - return link, nil - } - } - return nil, nil -} - -func (f fakeBackend) GetPermanent(path string, id uint) (*Link, error) { - for _, link := range f.links { - if link.Path == path && link.UserID == id && link.Expire == 0 { - return link, nil - } - } - return nil, nil -} - -func (f fakeBackend) Gets(path string, id uint) ([]*Link, error) { - var links []*Link - for _, link := range f.links { - if link.Path == path && link.UserID == id { - links = append(links, link) - } - } - return links, nil -} - -func (f fakeBackend) Save(_ *Link) error { - return nil -} - -func (f fakeBackend) Delete(_ string) error { - return nil -} - -func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error { - return nil -} - -func TestGetsByPathReturnsLinksFromAllUsers(t *testing.T) { - t.Parallel() - - expected := []*Link{ - {Hash: "a", Path: "/file.txt", UserID: 1}, - {Hash: "b", Path: "/file.txt", UserID: 2}, - } - store := NewStorage(fakeBackend{ - links: append(expected, &Link{Hash: "c", Path: "/other.txt", UserID: 3}), - }) - - links, err := store.GetsByPath("/file.txt") - if err != nil { - t.Fatalf("GetsByPath returned error: %v", err) - } - - if !reflect.DeepEqual(links, expected) { - t.Fatalf("GetsByPath returned %#v, want %#v", links, expected) - } -} diff --git a/storage/bolt/share.go b/storage/bolt/share.go index 621ea6f1..49b3f0c4 100644 --- a/storage/bolt/share.go +++ b/storage/bolt/share.go @@ -78,16 +78,17 @@ func (s shareBackend) Delete(hash string) error { } func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error { + // Share paths are stored without a trailing slash + prefix := strings.TrimRight(pathPrefix, "/") + var links []share.Link - if err := s.db.Prefix("Path", pathPrefix, &links); err != nil { + if err := s.db.Prefix("Path", prefix, &links); err != nil { if errors.Is(err, storm.ErrNotFound) { return nil } return err } - prefix := strings.TrimRight(pathPrefix, "/") - var err error for _, link := range links { if link.UserID != userID { diff --git a/storage/bolt/share_test.go b/storage/bolt/share_test.go index e303cec6..9f96a847 100644 --- a/storage/bolt/share_test.go +++ b/storage/bolt/share_test.go @@ -84,6 +84,44 @@ func TestDeleteWithPathPrefix(t *testing.T) { } } +// Regression for the trailing-slash delete leaving a stale share +// (GHSA-pp88-jhwj-5qh5): deleting "/a/" must remove the exact "/a" share and its +// descendants, not just the descendants. Siblings and other users are untouched. +func TestDeleteWithPathPrefixTrailingSlash(t *testing.T) { + t.Parallel() + + s := newTestShareBackend(t) + + links := []*share.Link{ + {Hash: "u1-a", Path: "/a", UserID: 1}, + {Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1}, + {Hash: "u1-abc", Path: "/abc", UserID: 1}, // sibling sharing a byte prefix + {Hash: "u2-a", Path: "/a", UserID: 2}, // other user, must remain + } + for _, l := range links { + if err := s.Save(l); err != nil { + t.Fatalf("failed to save link %s: %v", l.Hash, err) + } + } + + // Delete with a trailing slash, as the resource delete handler does for a + // directory request like DELETE /api/resources/a/. + if err := s.DeleteWithPathPrefix("/a/", 1); err != nil { + t.Fatalf("DeleteWithPathPrefix returned error: %v", err) + } + + got := remainingHashes(t, s) + want := []string{"u1-abc", "u2-a"} + if len(got) != len(want) { + t.Fatalf("remaining hashes = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("remaining hashes = %v, want %v", got, want) + } + } +} + func TestDeleteWithPathPrefixNoMatch(t *testing.T) { t.Parallel() diff --git a/storage/bolt/users.go b/storage/bolt/users.go index 6686d941..33f67abb 100644 --- a/storage/bolt/users.go +++ b/storage/bolt/users.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/asdine/storm/v3" + "github.com/asdine/storm/v3/q" bolt "go.etcd.io/bbolt" fberrors "github.com/filebrowser/filebrowser/v2/errors" @@ -41,6 +42,19 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) { return } +func (st usersBackend) GetByScope(scope string) (*users.User, error) { + user := &users.User{} + err := st.db.Select(q.Eq("Scope", scope)).First(user) + if err != nil { + if errors.Is(err, storm.ErrNotFound) { + return nil, fberrors.ErrNotExist + } + return nil, err + } + + return user, nil +} + func (st usersBackend) Gets() ([]*users.User, error) { var allUsers []*users.User err := st.db.All(&allUsers) diff --git a/users/storage.go b/users/storage.go index 33cfc9c4..ff10aeab 100644 --- a/users/storage.go +++ b/users/storage.go @@ -10,6 +10,7 @@ import ( // StorageBackend is the interface to implement for a users storage. type StorageBackend interface { GetBy(interface{}) (*User, error) + GetByScope(scope string) (*User, error) Gets() ([]*User, error) Save(u *User) error Update(u *User, fields ...string) error @@ -19,8 +20,9 @@ type StorageBackend interface { } type Store interface { - Get(baseScope string, id interface{}) (user *User, err error) - Gets(baseScope string) ([]*User, error) + Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error) + GetByScope(scope string) (*User, error) + Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) Update(user *User, fields ...string) error Save(user *User) error Delete(id interface{}) error @@ -45,26 +47,33 @@ func NewStorage(back StorageBackend) *Storage { // Get allows you to get a user by its name or username. The provided // id must be a string for username lookup or a uint for id lookup. If id // is neither, a ErrInvalidDataType will be returned. -func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error) { +func (s *Storage) Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error) { user, err = s.back.GetBy(id) if err != nil { return } - if err := user.Clean(baseScope); err != nil { + if err := user.Clean(baseScope, followExternalSymlinks); err != nil { return nil, err } return } +// GetByScope returns the first user whose scope matches the given one, or +// ErrNotExist if none does. The user is returned as stored, without setting up +// its filesystem, as it is meant for existence checks rather than serving. +func (s *Storage) GetByScope(scope string) (*User, error) { + return s.back.GetByScope(scope) +} + // Gets gets a list of all users. -func (s *Storage) Gets(baseScope string) ([]*User, error) { +func (s *Storage) Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) { users, err := s.back.Gets() if err != nil { return nil, err } for _, user := range users { - if err := user.Clean(baseScope); err != nil { + if err := user.Clean(baseScope, followExternalSymlinks); err != nil { return nil, err } } @@ -74,7 +83,7 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) { // Update updates a user in the database. func (s *Storage) Update(user *User, fields ...string) error { - err := user.Clean("", fields...) + err := user.Clean("", false, fields...) if err != nil { return err } @@ -92,7 +101,7 @@ func (s *Storage) Update(user *User, fields ...string) error { // Save saves the user in a storage. func (s *Storage) Save(user *User) error { - if err := user.Clean(""); err != nil { + if err := user.Clean("", false); err != nil { return err } diff --git a/users/users.go b/users/users.go index 6be3b09d..09db995a 100644 --- a/users/users.go +++ b/users/users.go @@ -19,23 +19,23 @@ const ( // User describes a user. type User struct { - ID uint `storm:"id,increment" json:"id"` - Username string `storm:"unique" json:"username"` - Password string `json:"password"` - Scope string `json:"scope"` - Locale string `json:"locale"` - LockPassword bool `json:"lockPassword"` - ViewMode ViewMode `json:"viewMode"` - SingleClick bool `json:"singleClick"` - RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"` - Perm Permissions `json:"perm"` - Commands []string `json:"commands"` - Sorting files.Sorting `json:"sorting"` - Fs *files.ScopedFs `json:"-" yaml:"-"` - Rules []rules.Rule `json:"rules"` - HideDotfiles bool `json:"hideDotfiles"` - DateFormat bool `json:"dateFormat"` - AceEditorTheme string `json:"aceEditorTheme"` + ID uint `storm:"id,increment" json:"id"` + Username string `storm:"unique" json:"username"` + Password string `json:"password"` + Scope string `json:"scope"` + Locale string `json:"locale"` + LockPassword bool `json:"lockPassword"` + ViewMode ViewMode `json:"viewMode"` + SingleClick bool `json:"singleClick"` + RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"` + Perm Permissions `json:"perm"` + Commands []string `json:"commands"` + Sorting files.Sorting `json:"sorting"` + Fs afero.Fs `json:"-" yaml:"-"` + Rules []rules.Rule `json:"rules"` + HideDotfiles bool `json:"hideDotfiles"` + DateFormat bool `json:"dateFormat"` + AceEditorTheme string `json:"aceEditorTheme"` } // GetRules implements rules.Provider. @@ -55,7 +55,7 @@ var checkableFields = []string{ // Clean cleans up a user and verifies if all its fields // are alright to be saved. -func (u *User) Clean(baseScope string, fields ...string) error { +func (u *User) Clean(baseScope string, followExternalSymlinks bool, fields ...string) error { if len(fields) == 0 { fields = checkableFields } @@ -92,7 +92,7 @@ func (u *User) Clean(baseScope string, fields ...string) error { if u.Fs == nil { scope := u.Scope scope = filepath.Join(baseScope, filepath.Join("/", scope)) - u.Fs = files.NewScopedFs(afero.NewOsFs(), scope) + u.Fs = files.NewFs(afero.NewOsFs(), scope, followExternalSymlinks) } return nil @@ -100,5 +100,5 @@ func (u *User) Clean(baseScope string, fields ...string) error { // FullPath gets the full path for a user's relative path. func (u *User) FullPath(path string) string { - return afero.FullBaseFsPath(u.Fs.Base(), path) + return afero.FullBaseFsPath(files.BasePath(u.Fs), path) } diff --git a/users/users_test.go b/users/users_test.go new file mode 100644 index 00000000..87171aaf --- /dev/null +++ b/users/users_test.go @@ -0,0 +1,43 @@ +package users + +import ( + "path/filepath" + "testing" + + "github.com/filebrowser/filebrowser/v2/files" + "github.com/spf13/afero" +) + +// TestUserCleanFs verifies that Clean builds the user filesystem according to the +// followExternalSymlinks flag and that FullPath resolves correctly for either +// implementation. +func TestUserCleanFs(t *testing.T) { + base := t.TempDir() + want := filepath.Join(base, "data", "x") + + t.Run("default builds a symlink-confining ScopedFs", func(t *testing.T) { + u := &User{Username: "u", Password: "p", Scope: "data"} + if err := u.Clean(base, false); err != nil { + t.Fatal(err) + } + if _, ok := u.Fs.(*files.ScopedFs); !ok { + t.Fatalf("expected *files.ScopedFs, got %T", u.Fs) + } + if got := u.FullPath("/x"); got != want { + t.Fatalf("FullPath: got %q, want %q", got, want) + } + }) + + t.Run("followExternalSymlinks builds a bare BasePathFs", func(t *testing.T) { + u := &User{Username: "u", Password: "p", Scope: "data"} + if err := u.Clean(base, true); err != nil { + t.Fatal(err) + } + if _, ok := u.Fs.(*afero.BasePathFs); !ok { + t.Fatalf("expected *afero.BasePathFs, got %T", u.Fs) + } + if got := u.FullPath("/x"); got != want { + t.Fatalf("FullPath: got %q, want %q", got, want) + } + }) +} diff --git a/www/docs/authentication.md b/www/docs/authentication.md index 4308b01c..24833f04 100644 --- a/www/docs/authentication.md +++ b/www/docs/authentication.md @@ -46,6 +46,10 @@ The Hook Authentication method in FileBrowser allows developers to delegate user The hook’s output controls user permissions, scope, locale, and other attributes, making it a powerful and extensible authentication mechanism. +> [!WARNING] +> +> The submitted username and password are attacker-controlled and are handed to your hook command as the `USERNAME` and `PASSWORD` environment variables. File Browser runs the command directly, without a shell, so the values themselves are inert. However, your script must treat them as untrusted: always quote them (`"$USERNAME"`, `"$PASSWORD"`) and never pass them unquoted to a shell, `eval`, `bash -c`, command substitution, or backticks. A hook script that shell-evaluates these values turns any login request into remote code execution. + For example, the following code delegates filebrowser authentication to a PowerShell script on Windows. You can configure any command (for example, a script in Python, Node.js, etc.). ```sh diff --git a/www/docs/cli/filebrowser-config-init.md b/www/docs/cli/filebrowser-config-init.md index bf13379e..c4e463cc 100644 --- a/www/docs/cli/filebrowser-config-init.md +++ b/www/docs/cli/filebrowser-config-init.md @@ -41,6 +41,7 @@ filebrowser config init [flags] --disableThumbnails disable image thumbnails --disableTypeDetectionByHeader disables type detection by reading file headers --fileMode string mode bits that new files are created with (default "0o640") + --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe) -h, --help help for init --hideDotfiles hide dotfiles in file listings --hideLoginButton hide login button from public pages diff --git a/www/docs/cli/filebrowser-config-set.md b/www/docs/cli/filebrowser-config-set.md index e17f7058..0b8684e0 100644 --- a/www/docs/cli/filebrowser-config-set.md +++ b/www/docs/cli/filebrowser-config-set.md @@ -38,6 +38,7 @@ filebrowser config set [flags] --disableThumbnails disable image thumbnails --disableTypeDetectionByHeader disables type detection by reading file headers --fileMode string mode bits that new files are created with (default "0o640") + --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe) -h, --help help for set --hideDotfiles hide dotfiles in file listings --hideLoginButton hide login button from public pages diff --git a/www/docs/cli/filebrowser.md b/www/docs/cli/filebrowser.md index ae76fdd9..266516bf 100644 --- a/www/docs/cli/filebrowser.md +++ b/www/docs/cli/filebrowser.md @@ -61,6 +61,7 @@ filebrowser [flags] --disablePreviewResize disable resize of image previews --disableThumbnails disable image thumbnails --disableTypeDetectionByHeader disables type detection by reading file headers + --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe) -h, --help help for filebrowser --imageProcessors int image processors count (default 4) -k, --key string tls key diff --git a/www/docs/deployment.md b/www/docs/deployment.md index aa2968fc..57676004 100644 --- a/www/docs/deployment.md +++ b/www/docs/deployment.md @@ -1,3 +1,15 @@ +## Self-Registration (Signup) + +File Browser allows you to enable user self-registration (signup). This can be enabled via **Settings → Global Settings**, or with `filebrowser config set --signup`. Self-registered users inherit the configured **user defaults**, including the scope. + +> [!WARNING] +> +> By default, the user scope is the server's root, so a self-registered user could read, +> modify, and delete every file File Browser serves. To prevent this, either: +> +> a. Enable `createUserDir` so each user gets their own directory; or +> b. If users are meant to share files, set the default scope to something other than the root. + ## Fail2ban File Browser does not natively support protection against brute force attacks. Therefore, we suggest using something like [fail2ban](https://github.com/fail2ban/fail2ban), which takes care of that by tracking the logs of your File Browser instance. For more information on how fail2ban works, please refer to their [wiki](https://github.com/fail2ban/fail2ban/wiki).