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>
This commit is contained in:
ShenLin
2026-09-01 18:29:22 +08:00
committed by jyxjjj
parent ce87b45d2d
commit f244adf60e
5 changed files with 145 additions and 15 deletions
+8
View File
@@ -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 {
+70 -11
View File
@@ -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) {
+17 -3
View File
@@ -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
+49
View File
@@ -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 {
+1 -1
View File
@@ -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