fix(storage): bind management operations to verified targets

This commit is contained in:
ranzhigg
2026-10-30 17:37:14 +08:00
parent f538c7276b
commit 80a76dee56
4 changed files with 205 additions and 10 deletions

View File

@@ -211,7 +211,7 @@ def list_files(
:param _: 依赖注入的已登录管理身份,仅用于权限校验
:return: 所有目录和文件
"""
_ensure_manage_storage_path(fileitem, _)
fileitem = _ensure_manage_storage_item(fileitem, _)
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
@@ -347,6 +347,74 @@ def _storage_child_item(fileitem: _SchemaFileItem, name: str) -> _SchemaFileItem
return fileitem.model_copy(update={"path": child_path})
def _storage_sibling_item(fileitem: _SchemaFileItem, name: str) -> _SchemaFileItem:
"""
按存储类型构造一个待校验的同级目标路径。
重命名接口实际把目标放在源对象的父目录中,不能复用创建子目录
使用的 ``当前路径/名称`` 计算方式。
"""
if (fileitem.storage or StorageSchema.Local.value) == StorageSchema.Local.value:
target_path = (Path(fileitem.path or "/").parent / name).as_posix()
else:
source_path = posixpath.normpath(f"/{(fileitem.path or '/').lstrip('/')}")
target_path = posixpath.join(posixpath.dirname(source_path), name)
return fileitem.model_copy(
update={
"path": target_path,
"fileid": None,
"parent_fileid": None,
}
)
def _storage_identity_matches(
requested: _SchemaFileItem,
actual: _SchemaFileItem,
) -> bool:
"""
确认客户端提交的对象标识与按路径查询到的真实对象一致。
``path`` 由客户端提供,只能作为查询条件,不能单独作为授权凭据。
远端存储的实际删除、重命名和目录浏览可能使用 ``fileid`` 或
``drive_id``,所以凡是客户端提交了这些标识,都必须与服务端回执
完全一致;缺少服务端回执时拒绝继续操作。
"""
if (requested.storage or StorageSchema.Local.value) != (
actual.storage or StorageSchema.Local.value
):
return False
for field in ("fileid", "drive_id"):
requested_value = getattr(requested, field, None)
if requested_value is not None and requested_value != getattr(actual, field, None):
return False
return True
def _resolve_verified_storage_item(
fileitem: _SchemaFileItem,
) -> Optional[_SchemaFileItem]:
"""
按路径查询并核验存储对象,返回可直接交给存储提供方的服务端对象。
这是 Agent 和普通管理用户的对象级授权边界:客户端路径只用于定位
候选对象,底层实际操作必须使用同一个已核验的 ``FileItem``,避免
合法路径搭配另一组远端文件 ID 或云盘空间标识。
"""
if not _is_agent_storage_path_allowed(fileitem):
return None
try:
actual = StorageChain().get_item(fileitem)
except Exception:
# 无法确认远端对象状态时必须拒绝,不能退回信任客户端字段。
return None
if not actual or not _storage_identity_matches(fileitem, actual):
return None
if not _is_agent_storage_path_allowed(actual):
return None
return actual
def _ensure_manage_storage_path(fileitem: _SchemaFileItem, current_user: Any) -> None:
"""
将非超级管理员的文件管理范围限制到已配置的下载或媒体库目录。
@@ -362,6 +430,24 @@ def _ensure_manage_storage_path(fileitem: _SchemaFileItem, current_user: Any) ->
raise HTTPException(status_code=403, detail="管理用户只能操作已配置的存储目录")
def _ensure_manage_storage_item(
fileitem: _SchemaFileItem,
current_user: Any,
) -> _SchemaFileItem:
"""
校验并返回非超级管理员实际可以操作的服务端对象。
超级管理员沿用原有完整文件管理能力;其他管理用户必须使用按路径
查询并完成身份绑定的对象,失败时拒绝访问。
"""
if not hasattr(current_user, "is_superuser") or current_user.is_superuser:
return fileitem
actual = _resolve_verified_storage_item(fileitem)
if not actual:
raise HTTPException(status_code=403, detail="无法验证存储对象或对象不在允许范围内")
return actual
@router.post(
"/agent/list",
summary="查询 Agent 可用目录和文件",
@@ -379,9 +465,10 @@ def list_agent_files(
"""
只允许 Agent 浏览已配置的下载或媒体库目录,不开放创建、改名或删除入口。
"""
if not _is_agent_storage_path_allowed(fileitem):
raise HTTPException(status_code=403, detail="Agent 只能浏览已配置的存储目录")
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
verified_item = _resolve_verified_storage_item(fileitem)
if not verified_item:
raise HTTPException(status_code=403, detail="Agent 只能浏览已验证的存储目录")
return _list_files(fileitem=verified_item, sort=sort, keyword=keyword)
@router.post("/mkdir", summary="创建目录", response_model=_SchemaResponse[None])
@@ -401,7 +488,7 @@ def mkdir(
return _SchemaResponse(success=False)
if not _is_safe_storage_child_name(name):
return _SchemaResponse(success=False, message="目录名不能包含路径分隔符")
_ensure_manage_storage_path(fileitem, _)
fileitem = _ensure_manage_storage_item(fileitem, _)
_ensure_manage_storage_path(_storage_child_item(fileitem, name), _)
result = StorageChain().create_folder(fileitem, name)
if result:
@@ -420,7 +507,7 @@ def delete(
:param fileitem: 文件项
:param _: 依赖注入的已登录管理身份,仅用于权限校验
"""
_ensure_manage_storage_path(fileitem, _)
fileitem = _ensure_manage_storage_item(fileitem, _)
result = StorageChain().delete_file(fileitem)
if result:
return _SchemaResponse(success=True)
@@ -450,7 +537,7 @@ def download(
:param fileitem: 文件项
:param _: 依赖注入的已登录管理身份,仅用于权限校验
"""
_ensure_manage_storage_path(fileitem, _)
fileitem = _ensure_manage_storage_item(fileitem, _)
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if tmp_file:
@@ -480,7 +567,7 @@ def image(
:param fileitem: 文件项
:param _: 依赖注入的已登录管理身份,仅用于权限校验
"""
_ensure_manage_storage_path(fileitem, _)
fileitem = _ensure_manage_storage_item(fileitem, _)
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if not tmp_file:
@@ -507,8 +594,8 @@ def rename(
return _SchemaResponse(success=False, message="新名称为空")
if not _is_safe_storage_child_name(new_name):
return _SchemaResponse(success=False, message="名称不能包含路径分隔符")
_ensure_manage_storage_path(fileitem, _)
_ensure_manage_storage_path(_storage_child_item(fileitem, new_name), _)
fileitem = _ensure_manage_storage_item(fileitem, _)
_ensure_manage_storage_path(_storage_sibling_item(fileitem, new_name), _)
# 重命名目录内文件
if recursive:

View File

@@ -595,6 +595,8 @@
"名称不能包含路径分隔符": "Names cannot contain path separators",
"管理用户只能操作已配置的存储目录": "Management users can only operate within configured storage directories",
"Agent 只能浏览已配置的存储目录": "Agents can only browse configured storage directories",
"无法验证存储对象或对象不在允许范围内": "The storage object could not be verified or is outside the allowed scope",
"Agent 只能浏览已验证的存储目录": "Agents can only browse verified storage directories",
"源文件路径为空": "Source file path is empty",
"未识别到媒体类型": "Media type could not be identified",
"站点域名未配置": "Site domain is not configured",

View File

@@ -283,6 +283,12 @@ def test_storage_agent_list_reuses_bounded_filter_and_sort(monkeypatch) -> None:
返回固定目录列表,不访问真实存储服务。
"""
def get_item(self, fileitem):
"""
返回通过服务端路径核验的目录项。
"""
return fileitem
def list_files(self, _fileitem):
"""
返回两个与自然名称顺序相反的目录项。

View File

@@ -115,3 +115,103 @@ def test_manage_storage_guard_blocks_non_superuser_outside_configured_roots(monk
escaped = storage_endpoint._storage_child_item(FileItem(path=str(allowed)), "../config")
assert not storage_endpoint._is_agent_storage_path_allowed(escaped)
def test_remote_object_identity_must_match_path_before_delete(monkeypatch):
"""
合法路径搭配云盘根目录或其他文件 ID 时必须拒绝,不能调用提供方。
"""
monkeypatch.setattr(
storage_endpoint.DirectoryHelper,
"get_dirs",
staticmethod(lambda: [_directory(download_path="/library", storage="u115")]),
)
monkeypatch.setattr(
storage_endpoint.StorageChain,
"get_item",
lambda _chain, _fileitem: FileItem(
storage="u115",
path="/library/",
fileid="42",
type="dir",
),
)
deleted = False
def _delete_file(_chain, _fileitem):
nonlocal deleted
deleted = True
return True
monkeypatch.setattr(storage_endpoint.StorageChain, "delete_file", _delete_file)
request_item = FileItem(storage="u115", path="/library", fileid="0", type="dir")
with pytest.raises(HTTPException) as exc_info:
storage_endpoint.delete(request_item, SimpleNamespace(is_superuser=False))
assert exc_info.value.status_code == 403
assert deleted is False
def test_verified_remote_object_is_used_for_provider_operation(monkeypatch):
"""
通过核验后,底层必须收到服务端返回的对象,而不是客户端原始对象。
"""
monkeypatch.setattr(
storage_endpoint.DirectoryHelper,
"get_dirs",
staticmethod(lambda: [_directory(download_path="/library", storage="u115")]),
)
actual = FileItem(
storage="u115",
path="/library/",
fileid="42",
drive_id="drive-a",
type="dir",
)
monkeypatch.setattr(
storage_endpoint.StorageChain,
"get_item",
lambda _chain, _fileitem: actual,
)
received = []
monkeypatch.setattr(
storage_endpoint.StorageChain,
"delete_file",
lambda _chain, fileitem: received.append(fileitem) or True,
)
request_item = FileItem(
storage="u115",
path="/library",
fileid="42",
drive_id="drive-a",
type="dir",
)
result = storage_endpoint.delete(request_item, SimpleNamespace(is_superuser=False))
assert result.success is True
assert received == [actual]
def test_rename_target_is_checked_in_source_parent(monkeypatch, tmp_path):
"""
重命名目标必须是源对象同级路径,配置根目录不能被移出允许范围。
"""
allowed = tmp_path / "library"
allowed.mkdir()
monkeypatch.setattr(
storage_endpoint.DirectoryHelper,
"get_dirs",
staticmethod(lambda: [_directory(download_path=str(allowed))]),
)
source = FileItem(path=str(allowed), type="dir")
target = storage_endpoint._storage_sibling_item(source, "moved")
assert target.path == str(tmp_path / "moved")
with pytest.raises(HTTPException) as exc_info:
storage_endpoint._ensure_manage_storage_path(
target,
SimpleNamespace(is_superuser=False),
)
assert exc_info.value.status_code == 403