diff --git a/internal/fs/list.go b/internal/fs/list.go index 1f92c7d46..113ba8231 100644 --- a/internal/fs/list.go +++ b/internal/fs/list.go @@ -2,13 +2,14 @@ package fs import ( "context" - "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "path" ) // List files @@ -43,7 +44,29 @@ func list(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) om.InitHideReg(meta.Hide) } objs := om.Merge(_objs, virtualFiles...) - return objs, nil + objs, err = filterReadableObjs(objs, user, path, meta) + return objs, err +} + +func filterReadableObjs(objs []model.Obj, user *model.User, reqPath string, parentMeta *model.Meta) ([]model.Obj, error) { + var result []model.Obj + for _, obj := range objs { + var meta *model.Meta + objPath := path.Join(reqPath, obj.GetName()) + if obj.IsDir() { + var err error + meta, err = op.GetNearestMeta(objPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return result, err + } + } else { + meta = parentMeta + } + if common.CanRead(user, meta, objPath) { + result = append(result, obj) + } + } + return result, nil } func whetherHide(user *model.User, meta *model.Meta, path string) bool { @@ -60,7 +83,7 @@ func whetherHide(user *model.User, meta *model.Meta, path string) bool { return false } // if meta doesn't apply to sub_folder, don't hide - if !utils.PathEqual(meta.Path, path) && !meta.HSub { + if !common.MetaCoversPath(meta.Path, path, meta.HSub) { return false } // if is guest, hide diff --git a/internal/fs/list_test.go b/internal/fs/list_test.go new file mode 100644 index 000000000..ebaf4371e --- /dev/null +++ b/internal/fs/list_test.go @@ -0,0 +1,151 @@ +package fs + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestWhetherHide(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user", + user: nil, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: false, + reason: "nil user (treated as admin) should not hide", + }, + { + name: "user with can_see_hides permission", + user: &model.User{ + Role: model.GENERAL, + Permission: 1, // bit 0 set = can see hides + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: false, + reason: "user with can_see_hides permission should not hide", + }, + { + name: "nil meta", + user: &model.User{ + Role: model.GUEST, + }, + meta: nil, + path: "/folder", + want: false, + reason: "nil meta should not hide", + }, + { + name: "empty hide string", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "", + HSub: true, + }, + path: "/folder", + want: false, + reason: "empty hide string should not hide", + }, + { + name: "exact path match with HSub=false", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: false, + }, + path: "/folder", + want: true, + reason: "exact path match should hide for guest", + }, + { + name: "sub path with HSub=true", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with HSub=true should hide for guest", + }, + { + name: "sub path with HSub=false", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with HSub=false should not hide", + }, + { + name: "non-sub path with HSub=true", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should not hide even with HSub=true", + }, + { + name: "user without can_see_hides permission", + user: &model.User{ + Role: model.GENERAL, + Permission: 0, // bit 0 not set = cannot see hides + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: true, + reason: "user without can_see_hides permission should hide", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := whetherHide(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("whetherHide() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} diff --git a/internal/model/meta.go b/internal/model/meta.go index 0446137a2..a105f38c6 100644 --- a/internal/model/meta.go +++ b/internal/model/meta.go @@ -1,16 +1,20 @@ package model type Meta struct { - ID uint `json:"id" gorm:"primaryKey"` - Path string `json:"path" gorm:"unique" binding:"required"` - Password string `json:"password"` - PSub bool `json:"p_sub"` - Write bool `json:"write"` - WSub bool `json:"w_sub"` - Hide string `json:"hide"` - HSub bool `json:"h_sub"` - Readme string `json:"readme"` - RSub bool `json:"r_sub"` - Header string `json:"header"` - HeaderSub bool `json:"header_sub"` + ID uint `json:"id" gorm:"primaryKey"` + Path string `json:"path" gorm:"unique" binding:"required"` + ReadUsers []uint `json:"read_users" gorm:"serializer:json"` + ReadUsersSub bool `json:"read_users_sub"` + WriteUsers []uint `json:"write_users" gorm:"serializer:json"` + WriteUsersSub bool `json:"write_users_sub"` + Password string `json:"password"` + PSub bool `json:"p_sub"` + Write bool `json:"write"` + WSub bool `json:"w_sub"` + Hide string `json:"hide"` + HSub bool `json:"h_sub"` + Readme string `json:"readme"` + RSub bool `json:"r_sub"` + Header string `json:"header"` + HeaderSub bool `json:"header_sub"` } diff --git a/internal/model/user.go b/internal/model/user.go index 3bad4ebb9..61252ce95 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -123,12 +123,12 @@ func (u *User) CanAddOfflineDownloadTasks() bool { return CanAddOfflineDownloadTasks(u.Permission) } -func CanWrite(permission int32) bool { +func CanWriteContent(permission int32) bool { return (permission>>3)&1 == 1 } -func (u *User) CanWrite() bool { - return CanWrite(u.Permission) +func (u *User) CanWriteContent() bool { + return CanWriteContent(u.Permission) } func CanRename(permission int32) bool { diff --git a/server/common/check.go b/server/common/check.go index 90074aeeb..c0b96d6b5 100644 --- a/server/common/check.go +++ b/server/common/check.go @@ -2,6 +2,7 @@ package common import ( "path" + "slices" "strings" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -17,24 +18,37 @@ func IsStorageSignEnabled(rawPath string) bool { return storage != nil && storage.GetStorage().EnableSign } -func CanWrite(meta *model.Meta, path string) bool { - if meta == nil || !meta.Write { +func CanRead(user *model.User, meta *model.Meta, path string) bool { + if user == nil { + return false + } + if meta != nil && len(meta.ReadUsers) > 0 && !slices.Contains(meta.ReadUsers, user.ID) && (meta.ReadUsersSub || meta.Path == path) { return false } - return meta.WSub || meta.Path == path + return true } -func IsApply(metaPath, reqPath string, applySub bool) bool { - if utils.PathEqual(metaPath, reqPath) { - return true +func CanWrite(user *model.User, meta *model.Meta, path string) bool { + if user == nil { + return false + } + if meta != nil && len(meta.WriteUsers) > 0 && !slices.Contains(meta.WriteUsers, user.ID) && (meta.WriteUsersSub || meta.Path == path) { + return false + } + return true +} + +func CanWriteContentBypassUserPerms(meta *model.Meta, path string) bool { + if meta == nil || !meta.Write { + return false } - return utils.IsSubPath(metaPath, reqPath) && applySub + return MetaCoversPath(meta.Path, path, meta.WSub) } func CanAccess(user *model.User, meta *model.Meta, reqPath string, password string) bool { // if the reqPath is in hide (only can check the nearest meta) and user can't see hides, can't access if meta != nil && !user.CanSeeHides() && meta.Hide != "" && - IsApply(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path + MetaCoversPath(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path for _, hide := range strings.Split(meta.Hide, "\n") { re := regexp2.MustCompile(hide, regexp2.None) if isMatch, _ := re.MatchString(path.Base(reqPath)); isMatch { @@ -42,6 +56,9 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri } } } + if !CanRead(user, meta, reqPath) { + return false + } // if is not guest and can access without password if user.CanAccessWithoutPassword() { return true @@ -51,13 +68,20 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri return true } // if meta doesn't apply to sub_folder, can access - if !utils.PathEqual(meta.Path, reqPath) && !meta.PSub { + if !MetaCoversPath(meta.Path, reqPath, meta.PSub) { return true } // validate password return meta.Password == password } +func MetaCoversPath(metaPath, reqPath string, applyToSubFolder bool) bool { + if utils.PathEqual(metaPath, reqPath) { + return true + } + return utils.IsSubPath(metaPath, reqPath) && applyToSubFolder +} + // ShouldProxy TODO need optimize // when should be proxy? // 1. config.MustProxy() diff --git a/server/common/check_test.go b/server/common/check_test.go index 33114603b..e6fa65244 100644 --- a/server/common/check_test.go +++ b/server/common/check_test.go @@ -1,24 +1,986 @@ package common -import "testing" +import ( + "testing" -func TestIsApply(t *testing.T) { - datas := []struct { + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestCoversPath(t *testing.T) { + tests := []struct { + name string metaPath string reqPath string applySub bool - result bool + want bool }{ { + name: "exact path match with applySub=false", + metaPath: "/folder", + reqPath: "/folder", + applySub: false, + want: true, + }, + { + name: "exact path match with applySub=true", + metaPath: "/folder", + reqPath: "/folder", + applySub: true, + want: true, + }, + { + name: "sub path with applySub=true", + metaPath: "/folder", + reqPath: "/folder/subfolder", + applySub: true, + want: true, + }, + { + name: "sub path with applySub=false", + metaPath: "/folder", + reqPath: "/folder/subfolder", + applySub: false, + want: false, + }, + { + name: "non-sub path with applySub=true", + metaPath: "/folder", + reqPath: "/other", + applySub: true, + want: false, + }, + { + name: "non-sub path with applySub=false", + metaPath: "/folder", + reqPath: "/other", + applySub: false, + want: false, + }, + { + name: "root path covers all with applySub=true", metaPath: "/", - reqPath: "/test", + reqPath: "/any/deep/path", + applySub: true, + want: true, + }, + { + name: "root path exact match", + metaPath: "/", + reqPath: "/", + applySub: false, + want: true, + }, + { + name: "deep sub path with applySub=true", + metaPath: "/folder", + reqPath: "/folder/sub1/sub2/file.txt", applySub: true, - result: true, + want: true, + }, + { + name: "sibling paths with applySub=true", + metaPath: "/folder1", + reqPath: "/folder2", + applySub: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MetaCoversPath(tt.metaPath, tt.reqPath, tt.applySub) + if got != tt.want { + t.Errorf("MetaCoversPath(%q, %q, %v) = %v, want %v", + tt.metaPath, tt.reqPath, tt.applySub, got, tt.want) + } + }) + } +} + +func TestCanWriteContentIgnoringUserPerms(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: false, + reason: "nil meta should deny write", + }, + { + name: "meta.Write=false", + meta: &model.Meta{ + Path: "/folder", + Write: false, + }, + path: "/folder", + want: false, + reason: "Write=false should deny write", + }, + { + name: "exact path match with WSub=false", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + }, + path: "/folder", + want: true, + reason: "exact path match should allow write", + }, + { + name: "sub path with WSub=true", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with WSub=true should allow write", + }, + { + name: "sub path with WSub=false (BEHAVIOR CHANGE)", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with WSub=false should deny write (fixed bug)", + }, + { + name: "non-sub path with WSub=true", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should deny write even with WSub=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanWriteContentBypassUserPerms(tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanWriteContentBypassUserPerms() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestCanRead(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user should deny access", + user: nil, + meta: nil, + path: "/any", + want: false, + reason: "nil user should not have read access", + }, + { + name: "nil meta should allow access", + user: &model.User{ + ID: 1, + }, + meta: nil, + path: "/any", + want: true, + reason: "nil meta means no restrictions", + }, + { + name: "empty ReadUsers list should allow access", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{}, + }, + path: "/folder", + want: true, + reason: "empty ReadUsers means no user-level restrictions", + }, + { + name: "user in ReadUsers list with exact path match", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user ID 1 is in ReadUsers list", + }, + { + name: "user not in ReadUsers list with exact path match", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder", + want: false, + reason: "user ID 5 is not in ReadUsers list and path matches", + }, + { + name: "user not in ReadUsers list with ReadUsersSub=true for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/folder/subfolder", + want: false, + reason: "user ID 5 is not in ReadUsers list and ReadUsersSub applies to sub paths", + }, + { + name: "user not in ReadUsers list with ReadUsersSub=false for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder/subfolder", + want: true, + reason: "ReadUsersSub=false means restriction doesn't apply to sub paths", + }, + { + name: "user in ReadUsers list with ReadUsersSub=true for sub path", + user: &model.User{ + ID: 2, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/folder/subfolder/deep", + want: true, + reason: "user ID 2 is in ReadUsers list so can access sub paths", + }, + { + name: "user not in ReadUsers list for different path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/other", + want: true, + reason: "meta path doesn't match request path, so restriction doesn't apply", + }, + { + name: "root level restriction with ReadUsersSub=true", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/any/deep/path", + want: false, + reason: "root level restriction with ReadUsersSub affects all paths", }, } - for i, data := range datas { - if IsApply(data.metaPath, data.reqPath, data.applySub) != data.result { - t.Errorf("TestIsApply %d failed", i) - } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanRead(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanRead() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s", + got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path) + } + }) + } +} + +func TestCanWrite(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user should deny access", + user: nil, + meta: nil, + path: "/any", + want: false, + reason: "nil user should not have write access", + }, + { + name: "nil meta should allow access", + user: &model.User{ + ID: 1, + }, + meta: nil, + path: "/any", + want: true, + reason: "nil meta means no restrictions", + }, + { + name: "empty WriteUsers list should allow access", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{}, + }, + path: "/folder", + want: true, + reason: "empty WriteUsers means no user-level restrictions", + }, + { + name: "user in WriteUsers list with exact path match", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user ID 1 is in WriteUsers list", + }, + { + name: "user not in WriteUsers list with exact path match", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "user ID 5 is not in WriteUsers list and path matches", + }, + { + name: "user not in WriteUsers list with WriteUsersSub=true for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: true, + }, + path: "/folder/subfolder", + want: false, + reason: "user ID 5 is not in WriteUsers list and WriteUsersSub applies to sub paths", + }, + { + name: "user not in WriteUsers list with WriteUsersSub=false for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder/subfolder", + want: true, + reason: "WriteUsersSub=false means restriction doesn't apply to sub paths", + }, + { + name: "user in WriteUsers list with WriteUsersSub=true for sub path", + user: &model.User{ + ID: 2, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: true, + }, + path: "/folder/subfolder/deep", + want: true, + reason: "user ID 2 is in WriteUsers list so can write to sub paths", + }, + { + name: "user not in WriteUsers list for different path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/other", + want: true, + reason: "meta path doesn't match request path, so restriction doesn't apply", + }, + { + name: "multiple users with mixed permissions", + user: &model.User{ + ID: 10, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 5, 10, 15}, + WriteUsersSub: true, + }, + path: "/folder/file.txt", + want: true, + reason: "user ID 10 is in WriteUsers list", + }, + { + name: "write restriction at root level", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/", + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/any/path", + want: false, + reason: "only user ID 1 can write when root has WriteUsers restriction", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanWrite(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanWrite() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s", + got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path) + } + }) + } +} + +func TestCanAccessWithReadPermissions(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + reqPath string + password string + want bool + reason string + }{ + { + name: "user with read permission and correct password", + user: &model.User{ + ID: 1, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "secret", + want: true, + reason: "user in ReadUsers list with correct password", + }, + { + name: "user without read permission even with correct password", + user: &model.User{ + ID: 5, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "secret", + want: false, + reason: "user not in ReadUsers list, should be denied before password check", + }, + { + name: "user with read permission but wrong password", + user: &model.User{ + ID: 1, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "wrong", + want: false, + reason: "user in ReadUsers list but wrong password", + }, + { + name: "user without read permission and no password", + user: &model.User{ + ID: 5, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + }, + reqPath: "/folder/file.txt", + password: "", + want: false, + reason: "user not in ReadUsers list should be denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanAccess(tt.user, tt.meta, tt.reqPath, tt.password) + if got != tt.want { + t.Errorf("CanAccess() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +// Helper function to safely get user ID +func getUserID(user *model.User) uint { + if user == nil { + return 0 + } + return user.ID +} + +// TestWritePermissionCombinations tests the combined permission check logic +// that is actually used in the codebase: +// +// if !user.CanWriteContent() && !CanWriteContentBypassUserPerms(meta, path) { +// deny +// } +// if !CanWrite(user, meta, path) { +// deny +// } +// +// This ensures the three-layer permission system works correctly: +// 1. User-level global write permission (CanWriteContent) +// 2. Meta-level global write permission (CanWriteContentBypassUserPerms) +// 3. Meta-level user whitelist (CanWrite) +func TestWritePermissionCombinations(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + checkFirstLayer bool // whether first layer should pass + checkSecondLayer bool // whether second layer should pass + expectedDenyReason string + }{ + // === Scenario 1: User has global write permission === + { + name: "user has CanWriteContent + in WriteUsers whitelist", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user has global write permission AND is in whitelist", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "user has CanWriteContent but NOT in WriteUsers whitelist", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{2, 3}, // user 1 not in list + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "even with global write permission, must pass whitelist check", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "whitelist check failed", + }, + + // === Scenario 2: User lacks global permission but meta.Write=true === + { + name: "no CanWriteContent + meta.Write=true + in WriteUsers", + user: &model.User{ + ID: 1, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, // bypass enabled + WSub: false, + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "meta.Write bypasses user permission check, and user is in whitelist", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + meta.Write=true + NOT in WriteUsers (KEY TEST)", + user: &model.User{ + ID: 5, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, // bypass enabled + WSub: false, + WriteUsers: []uint{1, 2, 3}, // user 5 not in list + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "CRITICAL: meta.Write cannot bypass whitelist check (new behavior)", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "whitelist check failed even with meta.Write=true", + }, + + // === Scenario 3: Both checks fail === + { + name: "no CanWriteContent + meta.Write=false", + user: &model.User{ + ID: 1, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, // no bypass + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "denied at first layer: no global permission and no bypass", + checkFirstLayer: false, + checkSecondLayer: false, + expectedDenyReason: "first layer check failed", + }, + + // === Scenario 4: Empty WriteUsers (no whitelist restriction) === + { + name: "user has CanWriteContent + empty WriteUsers", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{}, // empty = no restriction + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "empty WriteUsers means no whitelist restriction", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + meta.Write=true + empty WriteUsers", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + WriteUsers: []uint{}, // empty = no restriction + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "meta.Write bypasses first check, empty whitelist passes second", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + + // === Scenario 5: Nil meta (no restrictions) === + { + name: "user has CanWriteContent + nil meta", + user: &model.User{ + ID: 1, + Permission: 1 << 3, + }, + meta: nil, + path: "/folder", + want: true, + reason: "nil meta means no restrictions", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + nil meta", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: nil, + path: "/folder", + want: false, + reason: "nil meta cannot bypass lack of user permission", + checkFirstLayer: false, + checkSecondLayer: true, // would pass if first layer passed + expectedDenyReason: "first layer check failed", + }, + + // === Scenario 6: Sub-directory inheritance === + { + name: "meta.Write with WSub=true for subdirectory", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, // applies to subdirectories + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "WSub=true applies meta.Write to subdirectories", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "meta.Write with WSub=false for subdirectory", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, // does NOT apply to subdirectories + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "WSub=false means meta.Write doesn't apply to subdirectories", + checkFirstLayer: false, + checkSecondLayer: true, + expectedDenyReason: "first layer check failed (WSub=false)", + }, + { + name: "WriteUsersSub=false for subdirectory bypasses whitelist", + user: &model.User{ + ID: 5, // not in WriteUsers + Permission: 1 << 3, + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{1, 2}, + WriteUsersSub: false, // whitelist does NOT apply to subdirectories + }, + path: "/folder/subfolder", + want: true, + reason: "WriteUsersSub=false means whitelist doesn't apply to subdirectories", + checkFirstLayer: true, + checkSecondLayer: true, // passes because restriction doesn't apply + expectedDenyReason: "", + }, + + // === Scenario 7: Root level restriction === + { + name: "root level meta.Write with user in whitelist", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/", + Write: true, + WSub: true, + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/any/deep/path", + want: true, + reason: "root level permissions apply to all paths", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "root level restriction denies non-whitelisted user", + user: &model.User{ + ID: 5, + Permission: 1 << 3, // has global permission + }, + meta: &model.Meta{ + Path: "/", + Write: false, + WriteUsers: []uint{1, 2}, + WriteUsersSub: true, + }, + path: "/any/path", + want: false, + reason: "root level whitelist restricts all paths", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "not in root level whitelist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the actual permission check logic + firstLayerPass := tt.user.CanWriteContent() || CanWriteContentBypassUserPerms(tt.meta, tt.path) + secondLayerPass := CanWrite(tt.user, tt.meta, tt.path) + + // Verify our understanding of each layer + if firstLayerPass != tt.checkFirstLayer { + t.Errorf("First layer check mismatch: got %v, expected %v\n"+ + "CanWriteContent()=%v, CanWriteContentBypassUserPerms()=%v", + firstLayerPass, tt.checkFirstLayer, + tt.user.CanWriteContent(), CanWriteContentBypassUserPerms(tt.meta, tt.path)) + } + + if firstLayerPass && secondLayerPass != tt.checkSecondLayer { + t.Errorf("Second layer check mismatch: got %v, expected %v\n"+ + "CanWrite()=%v", + secondLayerPass, tt.checkSecondLayer, + CanWrite(tt.user, tt.meta, tt.path)) + } + + // Final result + got := firstLayerPass && secondLayerPass + + if got != tt.want { + t.Errorf("Permission check failed:\n"+ + " Result: %v, want %v\n"+ + " Reason: %s\n"+ + " First layer (CanWriteContent || CanWriteContentBypassUserPerms): %v\n"+ + " Second layer (CanWrite): %v\n"+ + " User: ID=%d, Permission=%d, CanWriteContent=%v\n"+ + " Meta: Path=%s, Write=%v, WSub=%v, WriteUsers=%v, WriteUsersSub=%v\n"+ + " Check Path: %s", + got, tt.want, + tt.reason, + firstLayerPass, + secondLayerPass, + tt.user.ID, tt.user.Permission, tt.user.CanWriteContent(), + getMetaPath(tt.meta), getMetaWrite(tt.meta), getMetaWSub(tt.meta), + getMetaWriteUsers(tt.meta), getMetaWriteUsersSub(tt.meta), + tt.path) + } + }) + } +} + +// Helper functions to safely extract meta fields +func getMetaPath(meta *model.Meta) string { + if meta == nil { + return "nil" + } + return meta.Path +} + +func getMetaWrite(meta *model.Meta) bool { + if meta == nil { + return false + } + return meta.Write +} + +func getMetaWSub(meta *model.Meta) bool { + if meta == nil { + return false + } + return meta.WSub +} + +func getMetaWriteUsers(meta *model.Meta) []uint { + if meta == nil { + return nil + } + return meta.WriteUsers +} + +func getMetaWriteUsersSub(meta *model.Meta) bool { + if meta == nil { + return false } + return meta.WriteUsersSub } diff --git a/server/ftp/fsmanage.go b/server/ftp/fsmanage.go index 48f72794e..3e98d6d14 100644 --- a/server/ftp/fsmanage.go +++ b/server/ftp/fsmanage.go @@ -15,20 +15,23 @@ import ( func Mkdir(ctx context.Context, path string) error { user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanFTPManage() { + return errs.PermissionDenied + } reqPath, err := user.JoinPath(path) if err != nil { return err } - if !user.CanWrite() || !user.CanFTPManage() { - meta, err := op.GetNearestMeta(stdpath.Dir(reqPath)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return err - } - } - if !common.CanWrite(meta, reqPath) { - return errs.PermissionDenied - } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return errs.PermissionDenied } return fs.MakeDir(ctx, reqPath) } @@ -42,6 +45,13 @@ func Remove(ctx context.Context, path string) error { if err != nil { return err } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !common.CanWrite(user, meta, reqPath) { + return errs.PermissionDenied + } if err = RemoveStage(reqPath); !errors.Is(err, errs.ObjectNotFound) { return err } @@ -60,8 +70,12 @@ func Rename(ctx context.Context, oldPath, newPath string) error { } srcDir, srcBase := stdpath.Split(srcPath) dstDir, dstBase := stdpath.Split(dstPath) + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } if srcDir == dstDir { - if !user.CanRename() || !user.CanFTPManage() { + if !user.CanRename() || !user.CanFTPManage() || !common.CanWrite(user, dstMeta, dstDir) { return errs.PermissionDenied } if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { @@ -69,7 +83,11 @@ func Rename(ctx context.Context, oldPath, newPath string) error { } return fs.Rename(ctx, srcPath, dstBase) } else { - if !user.CanFTPManage() || !user.CanMove() || (srcBase != dstBase && !user.CanRename()) { + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanMove() || !user.CanFTPManage() || (srcBase != dstBase && !user.CanRename()) || !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { return errs.PermissionDenied } if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { diff --git a/server/ftp/fsread.go b/server/ftp/fsread.go index 9080bae17..54a3de8f2 100644 --- a/server/ftp/fsread.go +++ b/server/ftp/fsread.go @@ -27,10 +27,8 @@ type FileDownloadProxy struct { func OpenDownload(ctx context.Context, reqPath string, offset int64) (*FileDownloadProxy, error) { user := ctx.Value(conf.UserKey).(*model.User) meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { @@ -121,10 +119,8 @@ func Stat(ctx context.Context, path string) (os.FileInfo, error) { return nil, err } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { @@ -147,10 +143,8 @@ func List(ctx context.Context, path string) ([]os.FileInfo, error) { return nil, err } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { diff --git a/server/ftp/fsup.go b/server/ftp/fsup.go index c549a1943..7a96a4f65 100644 --- a/server/ftp/fsup.go +++ b/server/ftp/fsup.go @@ -33,14 +33,18 @@ type FileUploadProxy struct { func uploadAuth(ctx context.Context, path string) error { user := ctx.Value(conf.UserKey).(*model.User) - meta, err := op.GetNearestMeta(stdpath.Dir(path)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return err - } + if !user.CanFTPManage() { + return errs.PermissionDenied + } + parentPath := stdpath.Dir(path) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied } - if !(common.CanAccess(user, meta, path, ctx.Value(conf.MetaPassKey).(string)) && - ((user.CanFTPManage() && user.CanWrite()) || common.CanWrite(meta, stdpath.Dir(path)))) { + if !common.CanWrite(user, parentMeta, parentPath) { return errs.PermissionDenied } return nil diff --git a/server/handles/archive.go b/server/handles/archive.go index 4fd405688..d46f83c86 100644 --- a/server/handles/archive.go +++ b/server/handles/archive.go @@ -101,11 +101,9 @@ func FsArchiveMeta(c *gin.Context, req *ArchiveMetaReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -186,11 +184,9 @@ func FsArchiveList(c *gin.Context, req *ArchiveListReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -264,6 +260,15 @@ func FsArchiveDecompress(c *gin.Context) { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } tasks := make([]task.TaskExtensionInfo, 0, len(srcPaths)) for _, srcPath := range srcPaths { t, e := fs.ArchiveDecompress(c.Request.Context(), srcPath, dstDir, model.ArchiveDecompressArgs{ diff --git a/server/handles/fsbatch.go b/server/handles/fsbatch.go index 162419f7b..28588d668 100644 --- a/server/handles/fsbatch.go +++ b/server/handles/fsbatch.go @@ -22,6 +22,7 @@ type RecursiveMoveReq struct { ConflictPolicy string `json:"conflict_policy"` } +// FsRecursiveMove recursively moves files (individual item permission checks skipped for performance). func FsRecursiveMove(c *gin.Context) { var req RecursiveMoveReq if err := c.ShouldBind(&req); err != nil { @@ -39,20 +40,31 @@ func FsRecursiveMove(c *gin.Context) { common.ErrorResp(c, err, 403) return } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } + common.GinWithValue(c, conf.MetaKey, srcMeta) + dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } - - meta, err := op.GetNearestMeta(srcDir) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } - common.GinWithValue(c, conf.MetaKey, meta) rootFiles, err := fs.List(c.Request.Context(), srcDir, &fs.ListArgs{}) if err != nil { @@ -143,6 +155,7 @@ type BatchRenameReq struct { } `json:"rename_objects"` } +// FsBatchRename performs batch rename (individual item permission checks skipped for performance). func FsBatchRename(c *gin.Context) { var req BatchRenameReq if err := c.ShouldBind(&req); err != nil { @@ -162,11 +175,13 @@ func FsBatchRename(c *gin.Context) { } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) for _, renameObject := range req.RenameObjects { @@ -193,6 +208,7 @@ type RegexRenameReq struct { NewNameRegex string `json:"new_name_regex"` } +// FsRegexRename renames files by regex (individual item permission checks skipped for performance). func FsRegexRename(c *gin.Context) { var req RegexRenameReq if err := c.ShouldBind(&req); err != nil { @@ -212,11 +228,13 @@ func FsRegexRename(c *gin.Context) { } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go index 62382a27c..cba11f35f 100644 --- a/server/handles/fsmanage.go +++ b/server/handles/fsmanage.go @@ -36,18 +36,19 @@ func FsMkdir(c *gin.Context) { common.ErrorResp(c, err, 403) return } - if !user.CanWrite() { - meta, err := op.GetNearestMeta(stdpath.Dir(reqPath)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } - } - if !common.CanWrite(meta, reqPath) { - common.ErrorResp(c, errs.PermissionDenied, 403) - return - } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } + if !common.CanWrite(user, parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } if err := fs.MakeDir(c.Request.Context(), reqPath); err != nil { common.ErrorResp(c, err, 500) @@ -65,6 +66,7 @@ type MoveCopyReq struct { Merge bool `json:"merge"` } +// FsMove performs batch move (individual item permission checks skipped for performance). func FsMove(c *gin.Context) { var req MoveCopyReq if err := c.ShouldBind(&req); err != nil { @@ -80,11 +82,34 @@ func FsMove(c *gin.Context) { common.ErrorResp(c, errs.PermissionDenied, 403) return } + srcDir, err := user.JoinPath(req.SrcDir) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } validPaths := make([]string, 0, len(req.Names)) for _, name := range req.Names { @@ -140,6 +165,7 @@ func FsMove(c *gin.Context) { } } +// FsCopy performs batch copy (individual item permission checks skipped for performance). func FsCopy(c *gin.Context) { var req MoveCopyReq if err := c.ShouldBind(&req); err != nil { @@ -155,11 +181,34 @@ func FsCopy(c *gin.Context) { common.ErrorResp(c, errs.PermissionDenied, 403) return } + srcDir, err := user.JoinPath(req.SrcDir) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanRead(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } validPaths := make([]string, 0, len(req.Names)) for _, name := range req.Names { @@ -245,6 +294,16 @@ func FsRename(c *gin.Context) { common.ErrorResp(c, err, 403) return } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } if !req.Overwrite { dstPath := stdpath.Join(stdpath.Dir(reqPath), req.Name) if dstPath != reqPath { @@ -273,6 +332,7 @@ type RemoveReq struct { Names []string `json:"names"` } +// FsRemove performs batch remove (individual item permission checks skipped for performance). func FsRemove(c *gin.Context) { var req RemoveReq if err := c.ShouldBind(&req); err != nil { @@ -288,6 +348,20 @@ func FsRemove(c *gin.Context) { common.ErrorResp(c, errs.PermissionDenied, 403) return } + reqPath, err := user.JoinPath(req.Dir) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } for i, name := range req.Names { if strings.TrimSpace(utils.FixAndCleanPath(name)) == "/" { log.Warnf("FsRemove: invalid item skipped: %s (parent directory: %s)\n", name, req.Dir) @@ -295,12 +369,7 @@ func FsRemove(c *gin.Context) { continue } // ensure req.Names is not a relative path - var err error - req.Names[i], err = user.JoinPath(stdpath.Join(req.Dir, name)) - if err != nil { - common.ErrorResp(c, err, 403) - return - } + req.Names[i] = stdpath.Join(reqPath, name) } for _, path := range req.Names { if path == "" { @@ -320,6 +389,7 @@ type RemoveEmptyDirectoryReq struct { SrcDir string `json:"src_dir"` } +// FsRemoveEmptyDirectory recursively removes empty directories (individual item permission checks skipped for performance). func FsRemoveEmptyDirectory(c *gin.Context) { var req RemoveEmptyDirectoryReq if err := c.ShouldBind(&req); err != nil { @@ -339,11 +409,13 @@ func FsRemoveEmptyDirectory(c *gin.Context) { } meta, err := op.GetNearestMeta(srcDir) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) diff --git a/server/handles/fsread.go b/server/handles/fsread.go index 886da9dc9..d4c1f6764 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -47,13 +47,14 @@ type ObjResp struct { } type FsListResp struct { - Content []ObjResp `json:"content"` - Total int64 `json:"total"` - Readme string `json:"readme"` - Header string `json:"header"` - Write bool `json:"write"` - Provider string `json:"provider"` - DirectUploadTools []string `json:"direct_upload_tools,omitempty"` + Content []ObjResp `json:"content"` + Total int64 `json:"total"` + Readme string `json:"readme"` + Header string `json:"header"` + Write bool `json:"write"` + WriteContentBypass bool `json:"write_content_bypass"` + Provider string `json:"provider"` + DirectUploadTools []string `json:"direct_upload_tools,omitempty"` } func FsListSplit(c *gin.Context) { @@ -83,21 +84,15 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { common.ErrorStrResp(c, "password is incorrect or you have no permission", 403) return } - if !user.CanWrite() && !common.CanWrite(meta, reqPath) && req.Refresh { - common.ErrorStrResp(c, "Refresh without permission", 403) - return - } objs, err := fs.List(c.Request.Context(), reqPath, &fs.ListArgs{ Refresh: req.Refresh, WithStorageDetails: !user.IsGuest() && !setting.GetBool(conf.HideStorageDetails), @@ -109,19 +104,20 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) { total, objs := pagination(objs, &req.PageReq) provider := "unknown" var directUploadTools []string - if user.CanWrite() { + if common.CanWrite(user, meta, reqPath) && (user.CanWriteContent() || common.CanWriteContentBypassUserPerms(meta, reqPath)) { if storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}); err == nil { directUploadTools = op.GetDirectUploadTools(storage) } } common.SuccessResp(c, FsListResp{ - Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)), - Total: int64(total), - Readme: getReadme(meta, reqPath), - Header: getHeader(meta, reqPath), - Write: user.CanWrite() || common.CanWrite(meta, reqPath), - Provider: provider, - DirectUploadTools: directUploadTools, + Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)), + Total: int64(total), + Readme: getReadme(meta, reqPath), + Header: getHeader(meta, reqPath), + Write: common.CanWrite(user, meta, reqPath), + WriteContentBypass: common.CanWriteContentBypassUserPerms(meta, reqPath), + Provider: provider, + DirectUploadTools: directUploadTools, }) } @@ -147,11 +143,9 @@ func FsDirs(c *gin.Context) { reqPath = tmp } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -186,14 +180,14 @@ func filterDirs(objs []model.Obj) []DirResp { } func getReadme(meta *model.Meta, path string) string { - if meta != nil && (utils.PathEqual(meta.Path, path) || meta.RSub) { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.RSub) { return meta.Readme } return "" } func getHeader(meta *model.Meta, path string) string { - if meta != nil && (utils.PathEqual(meta.Path, path) || meta.HeaderSub) { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.HeaderSub) { return meta.Header } return "" @@ -206,7 +200,7 @@ func isEncrypt(meta *model.Meta, path string) bool { if meta == nil || meta.Password == "" { return false } - if !utils.PathEqual(meta.Path, path) && !meta.PSub { + if !common.MetaCoversPath(meta.Path, path, meta.PSub) { return false } return true @@ -288,11 +282,9 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -414,11 +406,9 @@ func FsOther(c *gin.Context) { return } meta, err := op.GetNearestMeta(req.Path) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, req.Path, req.Password) { diff --git a/server/handles/fsread_test.go b/server/handles/fsread_test.go new file mode 100644 index 000000000..32e5d2b9c --- /dev/null +++ b/server/handles/fsread_test.go @@ -0,0 +1,255 @@ +package handles + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestGetReadme(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want string + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: "", + reason: "nil meta should return empty", + }, + { + name: "exact path match with RSub=false", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: false, + }, + path: "/folder", + want: "Welcome", + reason: "exact path should show readme", + }, + { + name: "sub path with RSub=true", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: true, + }, + path: "/folder/subfolder", + want: "Welcome", + reason: "sub path with RSub=true should show readme", + }, + { + name: "sub path with RSub=false", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: false, + }, + path: "/folder/subfolder", + want: "", + reason: "sub path with RSub=false should not show readme", + }, + { + name: "non-sub path with RSub=true (BEHAVIOR CHANGE - BUG FIX)", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: true, + }, + path: "/other", + want: "", + reason: "non-sub path should not show readme even with RSub=true (fixed bug)", + }, + { + name: "root readme applies to all with RSub=true", + meta: &model.Meta{ + Path: "/", + Readme: "Global Info", + RSub: true, + }, + path: "/any/path", + want: "Global Info", + reason: "root readme with RSub=true should apply to all paths", + }, + { + name: "empty readme", + meta: &model.Meta{ + Path: "/folder", + Readme: "", + RSub: true, + }, + path: "/folder", + want: "", + reason: "empty readme should return empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getReadme(tt.meta, tt.path) + if got != tt.want { + t.Errorf("getReadme() = %q, want %q\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestGetHeader(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want string + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: "", + reason: "nil meta should return empty", + }, + { + name: "exact path match with HeaderSub=false", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: false, + }, + path: "/folder", + want: "Custom Header", + reason: "exact path should show header", + }, + { + name: "sub path with HeaderSub=true", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: true, + }, + path: "/folder/subfolder", + want: "Custom Header", + reason: "sub path with HeaderSub=true should show header", + }, + { + name: "sub path with HeaderSub=false", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: false, + }, + path: "/folder/subfolder", + want: "", + reason: "sub path with HeaderSub=false should not show header", + }, + { + name: "non-sub path with HeaderSub=true (BEHAVIOR CHANGE - BUG FIX)", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: true, + }, + path: "/other", + want: "", + reason: "non-sub path should not show header even with HeaderSub=true (fixed bug)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getHeader(tt.meta, tt.path) + if got != tt.want { + t.Errorf("getHeader() = %q, want %q\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestIsEncrypt(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: false, + reason: "nil meta should not be encrypted", + }, + { + name: "empty password", + meta: &model.Meta{ + Path: "/folder", + Password: "", + }, + path: "/folder", + want: false, + reason: "empty password should not be encrypted", + }, + { + name: "exact path match with PSub=false", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: false, + }, + path: "/folder", + want: true, + reason: "exact path with password should be encrypted", + }, + { + name: "sub path with PSub=true", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with PSub=true should be encrypted", + }, + { + name: "sub path with PSub=false", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with PSub=false should not be encrypted", + }, + { + name: "non-sub path with PSub=true", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should not be encrypted even with PSub=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEncrypt(tt.meta, tt.path) + if got != tt.want { + t.Errorf("isEncrypt() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go index 153b27293..c1c014204 100644 --- a/server/handles/offline_download.go +++ b/server/handles/offline_download.go @@ -12,12 +12,14 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" "github.com/OpenListTeam/OpenList/v4/drivers/thunderx" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/task" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) type SetAria2Req struct { @@ -498,6 +500,15 @@ func AddOfflineDownload(c *gin.Context) { common.ErrorResp(c, err, 403) return } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } var tasks []task.TaskExtensionInfo for _, url := range req.Urls { // Filter out empty lines and whitespace-only strings diff --git a/server/middlewares/down.go b/server/middlewares/down.go index cb87eb3c3..c1f81b54b 100644 --- a/server/middlewares/down.go +++ b/server/middlewares/down.go @@ -25,11 +25,9 @@ func Down(verifyFunc func(string, string) error) func(c *gin.Context) { return func(c *gin.Context) { rawPath := c.Request.Context().Value(conf.PathKey).(string) meta, err := op.GetNearestMeta(rawPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorPage(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorPage(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) // verify sign diff --git a/server/middlewares/fsup.go b/server/middlewares/fsup.go index 08b160ee5..d99e62aea 100644 --- a/server/middlewares/fsup.go +++ b/server/middlewares/fsup.go @@ -15,7 +15,6 @@ import ( func FsUp(c *gin.Context) { path := c.GetHeader("File-Path") - password := c.GetHeader("Password") path, err := url.PathUnescape(path) if err != nil { common.ErrorResp(c, err, 400) @@ -28,15 +27,19 @@ func FsUp(c *gin.Context) { common.ErrorResp(c, err, 403) return } - meta, err := op.GetNearestMeta(stdpath.Dir(path)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - c.Abort() - return - } + parentPath := stdpath.Dir(path) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + c.Abort() + return + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + c.Abort() + return } - if !(common.CanAccess(user, meta, path, password) && (user.CanWrite() || common.CanWrite(meta, stdpath.Dir(path)))) { + if !common.CanWrite(user, parentMeta, parentPath) { common.ErrorResp(c, errs.PermissionDenied, 403) c.Abort() return diff --git a/server/webdav.go b/server/webdav.go index 789236b8b..aa1ee6ac1 100644 --- a/server/webdav.go +++ b/server/webdav.go @@ -117,22 +117,22 @@ func WebDAVAuth(c *gin.Context) { c.Abort() return } - if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && (!user.CanWebdavManage() || !user.CanWrite()) { + if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "MOVE" && (!user.CanWebdavManage() || (!user.CanMove() && !user.CanRename())) { + if c.Request.Method == "MOVE" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "COPY" && (!user.CanWebdavManage() || !user.CanCopy()) { + if c.Request.Method == "COPY" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "DELETE" && (!user.CanWebdavManage() || !user.CanRemove()) { + if c.Request.Method == "DELETE" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return @@ -143,6 +143,7 @@ func WebDAVAuth(c *gin.Context) { return } common.GinWithValue(c, conf.UserKey, user) + common.GinWithValue(c, conf.MetaPassKey, password) c.Next() } diff --git a/server/webdav/file.go b/server/webdav/file.go index debfcfe9e..ea6099735 100644 --- a/server/webdav/file.go +++ b/server/webdav/file.go @@ -11,9 +11,12 @@ import ( "path/filepath" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/pkg/errors" ) // slashClean is equivalent to but slightly more efficient than @@ -26,6 +29,7 @@ func slashClean(name string) string { } // moveFiles moves files and/or directories from src to dst. +// Individual item permission checks are skipped for performance reasons. // // See section 9.9.4 for when various HTTP status codes apply. func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) { @@ -40,6 +44,17 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int if srcName != dstName && !user.CanRename() { return http.StatusForbidden, nil } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { + return http.StatusForbidden, nil + } if srcDir == dstDir { err = fs.Rename(ctx, src, dstName) } else { @@ -59,10 +74,30 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int } // copyFiles copies files and/or directories from src to dst. +// Individual item permission checks are skipped for performance reasons. // // See section 9.8.5 for when various HTTP status codes apply. func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) { + srcDir := path.Dir(src) dstDir := path.Dir(dst) + user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanCopy() { + return http.StatusForbidden, nil + } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanRead(user, srcMeta, srcDir) { + return http.StatusForbidden, nil + } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, dstMeta, dstDir) { + return http.StatusForbidden, nil + } _, err = fs.Copy(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir) if err != nil { return http.StatusInternalServerError, err diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index 504c5fc1d..daa263be4 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -7,7 +7,6 @@ package webdav // import "golang.org/x/net/webdav" import ( "context" - "errors" "fmt" "io" "net/http" @@ -20,8 +19,10 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/net" + "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/pkg/errors" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" @@ -200,7 +201,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } allow := "OPTIONS, LOCK, PUT, MKCOL" if fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err == nil { @@ -226,10 +227,18 @@ func (h *Handler) handleGetHeadPost(w http.ResponseWriter, r *http.Request) (sta // TODO: check locks for read-only access?? ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) + password := ctx.Value(conf.MetaPassKey).(string) reqPath, err = user.JoinPath(reqPath) if err != nil { return http.StatusForbidden, err } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanAccess(user, meta, reqPath, password) { + return http.StatusForbidden, errs.PermissionDenied + } fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { return http.StatusNotFound, err @@ -294,9 +303,12 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanRemove() { + return http.StatusForbidden, nil + } reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } // TODO: return MultiStatus where appropriate. @@ -309,6 +321,14 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i } return http.StatusMethodNotAllowed, err } + parentPath := path.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } if err := fs.Remove(ctx, reqPath); err != nil { return http.StatusMethodNotAllowed, err } @@ -363,6 +383,17 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) { return http.StatusForbidden, errs.IgnoredSystemFile } + parentPath := path.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } fsStream := &stream.FileStream{ Obj: &obj, Reader: r.Body, @@ -407,7 +438,7 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } if r.ContentLength > 0 { @@ -421,13 +452,23 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in } // RFC 4918 9.3.1 // 409 (Conflict) The server MUST NOT create those intermediate collections automatically. - reqDir := path.Dir(reqPath) - if _, err := fs.Get(ctx, reqDir, &fs.GetArgs{}); err != nil { + parentPath := path.Dir(reqPath) + if _, err := fs.Get(ctx, parentPath, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) { return http.StatusConflict, err } return http.StatusMethodNotAllowed, err } + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } if err := fs.MakeDir(ctx, reqPath); err != nil { if os.IsNotExist(err) { return http.StatusConflict, err @@ -471,11 +512,11 @@ func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) (status user := ctx.Value(conf.UserKey).(*model.User) src, err = user.JoinPath(src) if err != nil { - return 403, err + return http.StatusForbidden, err } dst, err = user.JoinPath(dst) if err != nil { - return 403, err + return http.StatusForbidden, err } if r.Method == "COPY" { @@ -572,7 +613,14 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus } reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied } ld = LockDetails{ Root: reqPath, @@ -630,6 +678,24 @@ func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) (status i } t = t[1 : len(t)-1] + reqPath, status, err := h.stripPrefix(r.URL.Path) + if err != nil { + return status, err + } + ctx := r.Context() + user := ctx.Value(conf.UserKey).(*model.User) + reqPath, err = user.JoinPath(reqPath) + if err != nil { + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied + } + switch err = h.LockSystem.Unlock(time.Now(), t); err { case nil: return http.StatusNoContent, err @@ -653,9 +719,17 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status userAgent := r.Header.Get("User-Agent") ctx = context.WithValue(ctx, conf.UserAgentKey, userAgent) user := ctx.Value(conf.UserKey).(*model.User) + password := ctx.Value(conf.MetaPassKey).(string) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanAccess(user, meta, reqPath, password) { + return http.StatusForbidden, errs.PermissionDenied } fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { @@ -734,7 +808,14 @@ func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) (statu user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied } if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) {