mirror of
https://github.com/filebrowser/filebrowser.git
synced 2026-07-17 16:36:49 +00:00
fix: restore symlink behavior as opt-in followExternalSymlinks
This commit is contained in:
parent
64511ce45e
commit
a1063925e1
23 changed files with 384 additions and 93 deletions
|
|
@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings
|
||||||
case "block":
|
case "block":
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
case "pass":
|
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) {
|
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
|
||||||
return nil, os.ErrPermission
|
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
|
// SaveUser updates the existing user or creates a new one when not found
|
||||||
func (a *HookAuth) SaveUser() (*users.User, error) {
|
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) {
|
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
hash := dummyHash
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ type NoAuth struct{}
|
||||||
|
|
||||||
// Auth uses authenticates user 1.
|
// Auth uses authenticates user 1.
|
||||||
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
|
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.
|
// LoginPage tells that no auth doesn't require a login page.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ type ProxyAuth struct {
|
||||||
// Auth authenticates the user via an HTTP header.
|
// 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) {
|
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)
|
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) {
|
if errors.Is(err, fberrors.ErrNotExist) {
|
||||||
return a.createUser(usr, setting, srv, username)
|
return a.createUser(usr, setting, srv, username)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ type mockUserStore struct {
|
||||||
users map[string]*users.User
|
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 v, ok := id.(string); ok {
|
||||||
if u, ok := m.users[v]; ok {
|
if u, ok := m.users[v]; ok {
|
||||||
return u, nil
|
return u, nil
|
||||||
|
|
@ -22,14 +22,14 @@ func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
|
||||||
return nil, fberrors.ErrNotExist
|
return nil, fberrors.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil }
|
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) Update(_ *users.User, _ ...string) error { return nil }
|
||||||
func (m *mockUserStore) Save(user *users.User) error {
|
func (m *mockUserStore) Save(user *users.User) error {
|
||||||
m.users[user.Username] = user
|
m.users[user.Username] = user
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockUserStore) Delete(_ interface{}) error { 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) {
|
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
|
||||||
|
|
@ -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, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
|
||||||
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
|
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, "\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.Fprintln(w, "\nTUS:")
|
||||||
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
|
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
|
||||||
|
|
|
||||||
39
cmd/root.go
39
cmd/root.go
|
|
@ -111,6 +111,7 @@ func addServerFlags(flags *pflag.FlagSet) {
|
||||||
flags.Bool("disableExec", true, "disables Command Runner feature")
|
flags.Bool("disableExec", true, "disables Command Runner feature")
|
||||||
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
|
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("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{
|
var rootCmd = &cobra.Command{
|
||||||
|
|
@ -353,6 +354,10 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
|
||||||
server.EnableExec = !v.GetBool("disableExec")
|
server.EnableExec = !v.GetBool("disableExec")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v.IsSet("followExternalSymlinks") {
|
||||||
|
server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks")
|
||||||
|
}
|
||||||
|
|
||||||
if isAddrSet && isSocketSet {
|
if isAddrSet && isSocketSet {
|
||||||
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert")
|
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert")
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +374,13 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
|
||||||
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,19 +458,20 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
ser := &settings.Server{
|
ser := &settings.Server{
|
||||||
BaseURL: v.GetString("baseURL"),
|
BaseURL: v.GetString("baseURL"),
|
||||||
Port: v.GetString("port"),
|
Port: v.GetString("port"),
|
||||||
Log: v.GetString("log"),
|
Log: v.GetString("log"),
|
||||||
TLSKey: v.GetString("key"),
|
TLSKey: v.GetString("key"),
|
||||||
TLSCert: v.GetString("cert"),
|
TLSCert: v.GetString("cert"),
|
||||||
Address: v.GetString("address"),
|
Address: v.GetString("address"),
|
||||||
Root: v.GetString("root"),
|
Root: v.GetString("root"),
|
||||||
TokenExpirationTime: v.GetString("tokenExpirationTime"),
|
TokenExpirationTime: v.GetString("tokenExpirationTime"),
|
||||||
EnableThumbnails: !v.GetBool("disableThumbnails"),
|
EnableThumbnails: !v.GetBool("disableThumbnails"),
|
||||||
ResizePreview: !v.GetBool("disablePreviewResize"),
|
ResizePreview: !v.GetBool("disablePreviewResize"),
|
||||||
EnableExec: !v.GetBool("disableExec"),
|
EnableExec: !v.GetBool("disableExec"),
|
||||||
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
|
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
|
||||||
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
|
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
|
||||||
|
FollowExternalSymlinks: v.GetBool("followExternalSymlinks"),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.Settings.SaveServer(ser)
|
err = s.Settings.SaveServer(ser)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
|
||||||
}
|
}
|
||||||
if id != nil {
|
if id != nil {
|
||||||
var user *users.User
|
var user *users.User
|
||||||
user, err = st.Users.Get("", id)
|
user, err = st.Users.Get("", false, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ var usersExportCmd = &cobra.Command{
|
||||||
path to the file where you want to write the users.`,
|
path to the file where you want to write the users.`,
|
||||||
Args: jsonYamlArg,
|
Args: jsonYamlArg,
|
||||||
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
|
||||||
list, err := st.Users.Gets("")
|
list, err := st.Users.Gets("", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,14 @@ var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error
|
||||||
if len(args) == 1 {
|
if len(args) == 1 {
|
||||||
username, id := parseUsernameOrID(args[0])
|
username, id := parseUsernameOrID(args[0])
|
||||||
if username != "" {
|
if username != "" {
|
||||||
user, err = st.Users.Get("", username)
|
user, err = st.Users.Get("", false, username)
|
||||||
} else {
|
} else {
|
||||||
user, err = st.Users.Get("", id)
|
user, err = st.Users.Get("", false, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
list = []*users.User{user}
|
list = []*users.User{user}
|
||||||
} else {
|
} else {
|
||||||
list, err = st.Users.Gets("")
|
list, err = st.Users.Gets("", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ list or set it to 0.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range list {
|
for _, user := range list {
|
||||||
err = user.Clean("")
|
err = user.Clean("", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ list or set it to 0.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if replace {
|
if replace {
|
||||||
oldUsers, userImportErr := st.Users.Gets("")
|
oldUsers, userImportErr := st.Users.Gets("", false)
|
||||||
if userImportErr != nil {
|
if userImportErr != nil {
|
||||||
return userImportErr
|
return userImportErr
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +76,7 @@ list or set it to 0.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range list {
|
for _, user := range list {
|
||||||
onDB, err := st.Users.Get("", user.ID)
|
onDB, err := st.Users.Get("", false, user.ID)
|
||||||
|
|
||||||
// User exists in DB.
|
// User exists in DB.
|
||||||
if err == nil {
|
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
|
// with the new username. If there is, print an error and cancel the
|
||||||
// operation
|
// operation
|
||||||
if user.Username != onDB.Username {
|
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)
|
return usernameConflictError(user.Username, conflictuous.ID, user.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ options you want to change.`,
|
||||||
user *users.User
|
user *users.User
|
||||||
)
|
)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
user, err = st.Users.Get("", id)
|
user, err = st.Users.Get("", false, id)
|
||||||
} else {
|
} else {
|
||||||
user, err = st.Users.Get("", username)
|
user, err = st.Users.Get("", false, username)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
121
files/fs_test.go
Normal file
121
files/fs_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,8 +37,31 @@ func NewScopedFs(source afero.Fs, path string) *ScopedFs {
|
||||||
return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
|
return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base returns the underlying *afero.BasePathFs.
|
// NewFs builds a user filesystem rooted at path. When followExternal is true it
|
||||||
func (s *ScopedFs) Base() *afero.BasePathFs { return s.base }
|
// 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
|
// 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
|
// the underlying BasePathFs. This is needed by callers that need the actual
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ func withUser(fn handleFunc) handleFunc {
|
||||||
w.Header().Add("X-Renew-Token", "true")
|
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 {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
return status, err
|
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 {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
@ -63,11 +63,12 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
filePath = ifPath
|
filePath = ifPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// set fs root to the shared file/folder. ScopedFs (not a bare
|
// set fs root to the shared file/folder. Unless external symlinks are
|
||||||
// BasePathFs) so the share is also symlink-confined: a link inside the
|
// explicitly allowed, this is a ScopedFs (not a bare BasePathFs) so the
|
||||||
// shared subtree that points elsewhere in the owner's scope — outside
|
// share is also symlink-confined: a link inside the shared subtree that
|
||||||
// the share — must not be followed.
|
// points elsewhere in the owner's scope — outside the share — must not be
|
||||||
d.user.Fs = files.NewScopedFs(d.user.Fs, basePath)
|
// 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
|
// 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
|
// rule checker are relative to it. Resolve them back to the user's
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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
|
// public share as a zip must not pull in files reached through a symlinked
|
||||||
// descendant.
|
// descendant.
|
||||||
|
|
|
||||||
|
|
@ -269,16 +269,24 @@ func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http
|
||||||
type customFSUser struct {
|
type customFSUser struct {
|
||||||
users.Store
|
users.Store
|
||||||
fs afero.Fs
|
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) {
|
func (cu *customFSUser) Get(baseScope string, followExternalSymlinks bool, id interface{}) (*users.User, error) {
|
||||||
user, err := cu.Store.Get(baseScope, id)
|
user, err := cu.Store.Get(baseScope, followExternalSymlinks, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Mirror production (users.User init), where a user's filesystem is always a
|
// Inject a filesystem rooted at the test's temp scope, standing in for the
|
||||||
// scoped, symlink-confining ScopedFs rather than a bare afero.Fs.
|
// one users.User.Clean would build in production.
|
||||||
user.Fs = files.NewScopedFs(cu.fs, "/")
|
if cu.followExternal {
|
||||||
|
user.Fs = cu.fs
|
||||||
|
} else {
|
||||||
|
user.Fs = files.NewScopedFs(cu.fs, "/")
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ func withSelfOrAdmin(fn handleFunc) handleFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
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 {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
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) {
|
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) {
|
if errors.Is(err, fberrors.ErrNotExist) {
|
||||||
return http.StatusNotFound, err
|
return http.StatusNotFound, err
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +228,7 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var suser *users.User
|
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 {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,21 +47,22 @@ func (s *Settings) GetRules() []rules.Rule {
|
||||||
|
|
||||||
// Server specific settings.
|
// Server specific settings.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Root string `json:"root"`
|
Root string `json:"root"`
|
||||||
BaseURL string `json:"baseURL"`
|
BaseURL string `json:"baseURL"`
|
||||||
Socket string `json:"socket"`
|
Socket string `json:"socket"`
|
||||||
TLSKey string `json:"tlsKey"`
|
TLSKey string `json:"tlsKey"`
|
||||||
TLSCert string `json:"tlsCert"`
|
TLSCert string `json:"tlsCert"`
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Log string `json:"log"`
|
Log string `json:"log"`
|
||||||
EnableThumbnails bool `json:"enableThumbnails"`
|
EnableThumbnails bool `json:"enableThumbnails"`
|
||||||
ResizePreview bool `json:"resizePreview"`
|
ResizePreview bool `json:"resizePreview"`
|
||||||
EnableExec bool `json:"enableExec"`
|
EnableExec bool `json:"enableExec"`
|
||||||
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
||||||
ImageResolutionCal bool `json:"imageResolutionCalculation"`
|
ImageResolutionCal bool `json:"imageResolutionCalculation"`
|
||||||
AuthHook string `json:"authHook"`
|
AuthHook string `json:"authHook"`
|
||||||
TokenExpirationTime string `json:"tokenExpirationTime"`
|
TokenExpirationTime string `json:"tokenExpirationTime"`
|
||||||
|
FollowExternalSymlinks bool `json:"followExternalSymlinks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean cleans any variables that might need cleaning.
|
// Clean cleans any variables that might need cleaning.
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ type StorageBackend interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
Get(baseScope string, id interface{}) (user *User, err error)
|
Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error)
|
||||||
Gets(baseScope string) ([]*User, error)
|
Gets(baseScope string, followExternalSymlinks bool) ([]*User, error)
|
||||||
Update(user *User, fields ...string) error
|
Update(user *User, fields ...string) error
|
||||||
Save(user *User) error
|
Save(user *User) error
|
||||||
Delete(id interface{}) error
|
Delete(id interface{}) error
|
||||||
|
|
@ -45,26 +45,26 @@ func NewStorage(back StorageBackend) *Storage {
|
||||||
// Get allows you to get a user by its name or username. The provided
|
// 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
|
// id must be a string for username lookup or a uint for id lookup. If id
|
||||||
// is neither, a ErrInvalidDataType will be returned.
|
// 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)
|
user, err = s.back.GetBy(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := user.Clean(baseScope); err != nil {
|
if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets gets a list of all users.
|
// 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()
|
users, err := s.back.Gets()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if err := user.Clean(baseScope); err != nil {
|
if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) {
|
||||||
|
|
||||||
// Update updates a user in the database.
|
// Update updates a user in the database.
|
||||||
func (s *Storage) Update(user *User, fields ...string) error {
|
func (s *Storage) Update(user *User, fields ...string) error {
|
||||||
err := user.Clean("", fields...)
|
err := user.Clean("", false, fields...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ func (s *Storage) Update(user *User, fields ...string) error {
|
||||||
|
|
||||||
// Save saves the user in a storage.
|
// Save saves the user in a storage.
|
||||||
func (s *Storage) Save(user *User) error {
|
func (s *Storage) Save(user *User) error {
|
||||||
if err := user.Clean(""); err != nil {
|
if err := user.Clean("", false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,23 +19,23 @@ const (
|
||||||
|
|
||||||
// User describes a user.
|
// User describes a user.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `storm:"id,increment" json:"id"`
|
ID uint `storm:"id,increment" json:"id"`
|
||||||
Username string `storm:"unique" json:"username"`
|
Username string `storm:"unique" json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
LockPassword bool `json:"lockPassword"`
|
LockPassword bool `json:"lockPassword"`
|
||||||
ViewMode ViewMode `json:"viewMode"`
|
ViewMode ViewMode `json:"viewMode"`
|
||||||
SingleClick bool `json:"singleClick"`
|
SingleClick bool `json:"singleClick"`
|
||||||
RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"`
|
RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"`
|
||||||
Perm Permissions `json:"perm"`
|
Perm Permissions `json:"perm"`
|
||||||
Commands []string `json:"commands"`
|
Commands []string `json:"commands"`
|
||||||
Sorting files.Sorting `json:"sorting"`
|
Sorting files.Sorting `json:"sorting"`
|
||||||
Fs *files.ScopedFs `json:"-" yaml:"-"`
|
Fs afero.Fs `json:"-" yaml:"-"`
|
||||||
Rules []rules.Rule `json:"rules"`
|
Rules []rules.Rule `json:"rules"`
|
||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
AceEditorTheme string `json:"aceEditorTheme"`
|
AceEditorTheme string `json:"aceEditorTheme"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRules implements rules.Provider.
|
// GetRules implements rules.Provider.
|
||||||
|
|
@ -55,7 +55,7 @@ var checkableFields = []string{
|
||||||
|
|
||||||
// Clean cleans up a user and verifies if all its fields
|
// Clean cleans up a user and verifies if all its fields
|
||||||
// are alright to be saved.
|
// 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 {
|
if len(fields) == 0 {
|
||||||
fields = checkableFields
|
fields = checkableFields
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ func (u *User) Clean(baseScope string, fields ...string) error {
|
||||||
if u.Fs == nil {
|
if u.Fs == nil {
|
||||||
scope := u.Scope
|
scope := u.Scope
|
||||||
scope = filepath.Join(baseScope, filepath.Join("/", 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
|
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.
|
// FullPath gets the full path for a user's relative path.
|
||||||
func (u *User) FullPath(path string) string {
|
func (u *User) FullPath(path string) string {
|
||||||
return afero.FullBaseFsPath(u.Fs.Base(), path)
|
return afero.FullBaseFsPath(files.BasePath(u.Fs), path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
users/users_test.go
Normal file
43
users/users_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue