From f244adf60e5e69790fcb18372033392329251ba6 Mon Sep 17 00:00:00 2001 From: ShenLin <773933146@qq.com> Date: Tue, 1 Sep 2026 18:29:22 +0800 Subject: [PATCH] fix(meta): enforce case-insensitive access controls - Add an invalidated metadata snapshot for case-insensitive fallback lookups - Enforce segment-aware metadata coverage for passwords and download signatures - Add regression tests while preserving case-sensitive write authorization Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> --- internal/db/meta.go | 8 ++++ internal/op/meta.go | 81 ++++++++++++++++++++++++++++++++----- server/common/check.go | 20 +++++++-- server/common/check_test.go | 49 ++++++++++++++++++++++ server/middlewares/down.go | 2 +- 5 files changed, 145 insertions(+), 15 deletions(-) diff --git a/internal/db/meta.go b/internal/db/meta.go index 32eec2c38..b609261a6 100644 --- a/internal/db/meta.go +++ b/internal/db/meta.go @@ -13,6 +13,14 @@ func GetMetaByPath(path string) (*model.Meta, error) { return &meta, nil } +func GetAllMetas() ([]model.Meta, error) { + var metas []model.Meta + if err := db.Find(&metas).Error; err != nil { + return nil, errors.Wrapf(err, "failed get all metas") + } + return metas, nil +} + func GetMetaById(id uint) (*model.Meta, error) { var u model.Meta if err := db.First(&u, id).Error; err != nil { diff --git a/internal/op/meta.go b/internal/op/meta.go index b7d867307..526332b25 100644 --- a/internal/op/meta.go +++ b/internal/op/meta.go @@ -2,6 +2,8 @@ package op import ( stdpath "path" + "strings" + "sync" "time" "github.com/OpenListTeam/OpenList/v4/internal/db" @@ -15,6 +17,9 @@ import ( ) var metaCache = cache.NewMemCache(cache.WithShards[*model.Meta](2)) +var metaSnapshotMu sync.Mutex +var metaSnapshot []model.Meta +var metaSnapshotLoaded bool // metaG maybe not needed var metaG singleflight.Group[*model.Meta] @@ -23,17 +28,56 @@ func GetNearestMeta(path string) (*model.Meta, error) { return getNearestMeta(utils.FixAndCleanPath(path)) } func getNearestMeta(path string) (*model.Meta, error) { - meta, err := GetMetaByPath(path) - if err == nil { - return meta, nil + var metas []model.Meta + loaded := false + for { + meta, err := GetMetaByPath(path) + if err == nil { + return meta, nil + } + if errors.Cause(err) != errs.MetaNotFound { + return nil, err + } + if !loaded { + metas, err = getMetaSnapshot() + if err != nil { + return nil, err + } + loaded = true + } + var matched *model.Meta + for i := range metas { + if !strings.EqualFold(utils.FixAndCleanPath(metas[i].Path), path) { + continue + } + if matched != nil { + return nil, errors.Errorf("multiple metas match %q case-insensitively", path) + } + matched = &metas[i] + } + if matched != nil { + return matched, nil + } + if path == "/" { + return nil, errs.MetaNotFound + } + path = stdpath.Dir(path) } - if errors.Cause(err) != errs.MetaNotFound { +} + +func getMetaSnapshot() ([]model.Meta, error) { + metaSnapshotMu.Lock() + defer metaSnapshotMu.Unlock() + if metaSnapshotLoaded { + return metaSnapshot, nil + } + metas, err := db.GetAllMetas() + if err != nil { return nil, err } - if path == "/" { - return nil, errs.MetaNotFound - } - return getNearestMeta(stdpath.Dir(path)) + metaSnapshot = metas + metaSnapshotLoaded = true + return metaSnapshot, nil } func GetMetaByPath(path string) (*model.Meta, error) { @@ -68,7 +112,12 @@ func DeleteMetaById(id uint) error { return err } metaCache.Del(old.Path) - return db.DeleteMetaById(id) + metaSnapshotMu.Lock() + defer metaSnapshotMu.Unlock() + err = db.DeleteMetaById(id) + metaSnapshot = nil + metaSnapshotLoaded = false + return err } func UpdateMeta(u *model.Meta) error { @@ -79,13 +128,23 @@ func UpdateMeta(u *model.Meta) error { } metaCache.Del(old.Path) metaCache.Del(u.Path) - return db.UpdateMeta(u) + metaSnapshotMu.Lock() + defer metaSnapshotMu.Unlock() + err = db.UpdateMeta(u) + metaSnapshot = nil + metaSnapshotLoaded = false + return err } func CreateMeta(u *model.Meta) error { u.Path = utils.FixAndCleanPath(u.Path) metaCache.Del(u.Path) - return db.CreateMeta(u) + metaSnapshotMu.Lock() + defer metaSnapshotMu.Unlock() + err := db.CreateMeta(u) + metaSnapshot = nil + metaSnapshotLoaded = false + return err } func GetMetaById(id uint) (*model.Meta, error) { diff --git a/server/common/check.go b/server/common/check.go index 2c8d3bc8b..728897013 100644 --- a/server/common/check.go +++ b/server/common/check.go @@ -44,7 +44,10 @@ func CanWriteContentBypassUserPerms(meta *model.Meta, path string) bool { if meta == nil || !meta.Write { return false } - return MetaCoversPath(meta.Path, path, meta.WSub) + if utils.PathEqual(meta.Path, path) { + return true + } + return utils.IsSubPath(meta.Path, path) && meta.WSub } func CanAccess(user *model.User, meta *model.Meta, reqPath string, password string) bool { @@ -78,10 +81,21 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri } func MetaCoversPath(metaPath, reqPath string, applyToSubFolder bool) bool { - if utils.PathEqual(metaPath, reqPath) { + metaPath = utils.FixAndCleanPath(metaPath) + reqPath = utils.FixAndCleanPath(reqPath) + if strings.EqualFold(metaPath, reqPath) { return true } - return utils.IsSubPath(metaPath, reqPath) && applyToSubFolder + if !applyToSubFolder { + return false + } + for reqPath != "/" { + reqPath = path.Dir(reqPath) + if strings.EqualFold(metaPath, reqPath) { + return true + } + } + return false } // ShouldProxy TODO need optimize diff --git a/server/common/check_test.go b/server/common/check_test.go index 18abca8e9..631cf2667 100644 --- a/server/common/check_test.go +++ b/server/common/check_test.go @@ -84,6 +84,27 @@ func TestCoversPath(t *testing.T) { applySub: true, want: false, }, + { + name: "case-insensitive exact path match", + metaPath: "/Folder", + reqPath: "/folder", + applySub: false, + want: true, + }, + { + name: "case-insensitive sub path match", + metaPath: "/Folder", + reqPath: "/folder/Subfolder", + applySub: true, + want: true, + }, + { + name: "case-insensitive sibling prefix does not match", + metaPath: "/Folder", + reqPath: "/folder-name", + applySub: true, + want: false, + }, } for _, tt := range tests { @@ -166,6 +187,17 @@ func TestCanWriteContentIgnoringUserPerms(t *testing.T) { want: false, reason: "non-sub path should deny write even with WSub=true", }, + { + name: "case-insensitive match does not broaden write bypass", + meta: &model.Meta{ + Path: "/Folder", + Write: true, + WSub: true, + }, + path: "/folder/subfolder", + want: false, + reason: "case-insensitive matching should only enforce restrictions, not grant write bypass", + }, } for _, tt := range tests { @@ -579,6 +611,23 @@ func TestCanAccessWithReadPermissions(t *testing.T) { want: false, reason: "user not in ReadUsers list should be denied", }, + { + name: "case-insensitive exact path still requires password", + user: &model.User{ + ID: 1, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/Folder", + Password: "secret", + PSub: false, + }, + reqPath: "/folder", + password: "wrong", + want: false, + reason: "changing only path casing must not bypass an exact-path password", + }, } for _, tt := range tests { diff --git a/server/middlewares/down.go b/server/middlewares/down.go index d71be00b3..787936d66 100644 --- a/server/middlewares/down.go +++ b/server/middlewares/down.go @@ -60,7 +60,7 @@ func needSign(meta *model.Meta, path string) bool { if meta == nil || meta.Password == "" { return false } - if !meta.PSub && path != meta.Path { + if !meta.PSub && !common.MetaCoversPath(meta.Path, path, false) { return false } return true