diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 67a9faf45..fb3778ae0 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -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: diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 96b7e45d4..c874cd5ae 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -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", diff --git a/tests/test_agent_api_projection_endpoints.py b/tests/test_agent_api_projection_endpoints.py index 0d086f444..7926a489f 100644 --- a/tests/test_agent_api_projection_endpoints.py +++ b/tests/test_agent_api_projection_endpoints.py @@ -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): """ 返回两个与自然名称顺序相反的目录项。 diff --git a/tests/test_storage_agent_security.py b/tests/test_storage_agent_security.py index 328d07f10..a8304838c 100644 --- a/tests/test_storage_agent_security.py +++ b/tests/test_storage_agent_security.py @@ -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