From 914abf6273a899659bbfcc2f38a4f0a61cbe9bcc Mon Sep 17 00:00:00 2001 From: ranzhigg Date: Fri, 30 Oct 2026 16:14:49 +0800 Subject: [PATCH 1/4] fix: tighten API authorization boundaries --- app/agent/policy/api.py | 10 +- app/agent/policy/mcp.py | 10 +- .../policy/resources/api_mcp_schema.json | 10 +- app/api/endpoints/agent.py | 7 +- app/api/endpoints/history.py | 2 +- app/api/endpoints/message.py | 2 +- app/api/endpoints/storage.py | 157 +++++++++++++++++- app/api/endpoints/subscribe.py | 20 ++- app/api/endpoints/transfer.py | 2 +- tests/test_agent_api_identity.py | 19 +++ tests/test_agent_api_projection_endpoints.py | 16 +- tests/test_api_authorization.py | 18 +- tests/test_storage_agent_security.py | 117 +++++++++++++ 13 files changed, 360 insertions(+), 30 deletions(-) create mode 100644 tests/test_storage_agent_security.py diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 474f0fc42..9f181fbf2 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -480,15 +480,15 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( sensitivity=ResultSensitivity.PRIVATE, ), _user_write("subscription.share", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE), - _user_write( + _write( "subscription.share.delete", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE, ), _user_write("subscription.fork", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE), - _spec("subscription.follow.list"), - _user_write("subscription.follow.add"), - _user_write("subscription.follow.delete"), + _admin_read("subscription.follow.list"), + _write("subscription.follow.add"), + _write("subscription.follow.delete"), _public_read("subscription.share.statistics"), _spec( "storage.manage", @@ -506,7 +506,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( recovery=RecoveryMode.MANUAL_ONLY, ), _spec("transfer.queue", result_sensitivity=ResultSensitivity.PRIVATE), - _user_write( + _write( "transfer.queue.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.RECONCILE, diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 92904542e..1e98bfddb 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -170,9 +170,9 @@ OPERATION_DESCRIPTIONS = { "subscription.execution.get": "读取当前用户可见的一个持久化订阅搜索批次。", "subscription.execution.cancel": "请求取消一个尚未越过下载副作用边界的持久化订阅搜索批次。", "subscription.find": "按规范媒体身份和可选季查找一个可访问订阅。", - "subscription.follow.add": "按精确分享用户标识关注一个订阅分享用户。", - "subscription.follow.delete": "按精确分享用户标识取消关注一个订阅分享用户。", - "subscription.follow.list": "列出当前用户关注的订阅分享用户标识。", + "subscription.follow.add": "修改实例级关注的订阅分享用户列表。", + "subscription.follow.delete": "从实例级关注列表移除一个订阅分享用户。", + "subscription.follow.list": "读取实例级关注的订阅分享用户列表。", "subscription.fork": "根据共享订阅定义创建一个本地订阅。", "subscription.get": "按持久化订阅标识读取一个可访问订阅。", "subscription.history.delete": "删除一条可访问的订阅历史记录。", @@ -181,7 +181,7 @@ OPERATION_DESCRIPTIONS = { "subscription.reset": "重置一个可访问订阅,使其可以再次处理。", "subscription.search_all": "立即搜索当前用户可访问的全部订阅。", "subscription.share": "把一个可访问订阅发布到 MoviePilot 分享服务。", - "subscription.share.delete": "按分享标识删除一条共享订阅发布记录。", + "subscription.share.delete": "按分享标识删除一条实例级共享订阅发布记录。", "subscription.share.statistics": "读取订阅分享者的贡献与复用次数汇总。", "subscription.status.update": "把一个可访问订阅设为运行、暂停或停止。", "subscription.user.list": "列出指定可访问 MoviePilot 用户名下的公开订阅。", @@ -209,7 +209,7 @@ OPERATION_DESCRIPTIONS = { "transfer.manual_reviews": "分页读取等待人工复核或重试恢复的持久化整理任务。", "transfer.name": "为源路径和媒体身份预览整理后的目标名称。", "transfer.queue": "列出文件整理队列中的待处理项目。", - "transfer.queue.delete": "从整理队列移除一个精确存储项目,并停止其整理。", + "transfer.queue.delete": "从实例整理队列移除一个精确存储项目,并停止其整理。", "transfer.target_path": "根据给定源存储项目解析已配置的整理目标。", "workflow.actions": "列出内置工作流动作定义及参数契约。", "workflow.create": "根据完整定义创建一个工作流。", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 3ad90b727..cc1f92673 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -13603,7 +13603,7 @@ }, { "additionalProperties": false, - "description": "按精确分享用户标识关注一个订阅分享用户。请求方法:POST;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:bound_user。所需角色:user。", + "description": "修改实例级关注的订阅分享用户列表。请求方法:POST;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:admin。所需角色:system_admin。", "properties": { "operation_id": { "const": "subscription.follow.add", @@ -13638,7 +13638,7 @@ }, { "additionalProperties": false, - "description": "按精确分享用户标识取消关注一个订阅分享用户。请求方法:DELETE;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:bound_user。所需角色:user。", + "description": "从实例级关注列表移除一个订阅分享用户。请求方法:DELETE;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:admin。所需角色:system_admin。", "properties": { "operation_id": { "const": "subscription.follow.delete", @@ -13673,7 +13673,7 @@ }, { "additionalProperties": false, - "description": "列出当前用户关注的订阅分享用户标识。请求方法:GET;路径:/api/v1/subscribe/follow。操作效果:safe_read;身份范围:bound_user。所需角色:user。集合响应:data 保持列表形式;同时省略 page 和 count 可保留原有完整结果。网关成功响应会增加 collection.result_count 和精确的 collection.total_count。查询数量或摘要时,请传入 page=1 和 count=1,再读取 collection.total_count;不要仅因条目数据被截断就查询数据库。", + "description": "读取实例级关注的订阅分享用户列表。请求方法:GET;路径:/api/v1/subscribe/follow。操作效果:safe_read;身份范围:admin。所需角色:system_admin。集合响应:data 保持列表形式;同时省略 page 和 count 可保留原有完整结果。网关成功响应会增加 collection.result_count 和精确的 collection.total_count。查询数量或摘要时,请传入 page=1 和 count=1,再读取 collection.total_count;不要仅因条目数据被截断就查询数据库。", "properties": { "operation_id": { "const": "subscription.follow.list", @@ -14274,7 +14274,7 @@ }, { "additionalProperties": false, - "description": "按分享标识删除一条共享订阅发布记录。请求方法:DELETE;路径:/api/v1/subscribe/share/{share_id}。操作效果:external_side_effect;身份范围:bound_user。所需角色:user。", + "description": "按分享标识删除一条实例级共享订阅发布记录。请求方法:DELETE;路径:/api/v1/subscribe/share/{share_id}。操作效果:external_side_effect;身份范围:admin。所需角色:system_admin。", "properties": { "operation_id": { "const": "subscription.share.delete", @@ -15979,7 +15979,7 @@ }, { "additionalProperties": false, - "description": "从整理队列移除一个精确存储项目,并停止其整理。请求方法:DELETE;路径:/api/v1/transfer/queue。操作效果:destructive_write;身份范围:bound_user。所需角色:user。", + "description": "从实例整理队列移除一个精确存储项目,并停止其整理。请求方法:DELETE;路径:/api/v1/transfer/queue。操作效果:destructive_write;身份范围:admin。所需角色:system_admin。", "properties": { "body": { "$ref": "#/$defs/FileItem-Input", diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index f88828771..29e1f9909 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -20,7 +20,7 @@ from fastapi import ( ) from fastapi.responses import FileResponse, StreamingResponse -from app.adapters.web.security.access import verify_token +from app.adapters.web.security.access import verify_resource_token, verify_token from app.agent.mcp import agent_mcp_manager from app.api.dependencies.agent import ( get_agent_chat_persistence, @@ -233,7 +233,10 @@ def _build_web_agent_sse( } }, ) -async def download_web_agent_file(file_id: str) -> FileResponse: +async def download_web_agent_file( + file_id: str, + _: _SchemaTokenPayload = Depends(verify_resource_token), +) -> FileResponse: """ 下载 Web 智能助手本轮生成的临时附件。 diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index b404f9b90..59fb93ed3 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -284,7 +284,7 @@ async def download_history( def delete_download_history( history_in: _SchemaDownloadHistory, command: DownloadHistoryMutationCommand = Depends(get_download_history_mutation_command), - _: _SchemaTokenPayload = Depends(verify_token), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 删除下载历史记录 diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 70316da36..b4fded0a4 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -256,7 +256,7 @@ async def get_notification_message( ) async def clear_notification_message( scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All, - _: _SchemaTokenPayload = Depends(verify_token), + _: Any = Depends(get_current_active_superuser), ) -> _SchemaResponse[dict[str, Any]]: """ 记录通知中心清理时间,后续通知历史查询会在服务端过滤。 diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 873915070..284599953 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -4,6 +4,7 @@ import fnmatch import math +import posixpath import re from pathlib import Path from typing import Any, Dict, List, Optional @@ -210,6 +211,7 @@ def list_files( :param _: 依赖注入的已登录管理身份,仅用于权限校验 :return: 所有目录和文件 """ + _ensure_manage_storage_path(fileitem, _) return _list_files(fileitem=fileitem, sort=sort, keyword=keyword) @@ -234,6 +236,130 @@ def _list_files( return file_list or [] +def _split_agent_storage_path(value: Optional[str], default_storage: str) -> tuple[str, str]: + """ + 拆分 Agent 文件浏览使用的存储标识和路径。 + + Agent 端点不应把用户提交的路径直接交给存储提供方;这里先统一 + ``storage:/path`` 与 ``/path`` 两种表示,后续只和已配置目录根比较。 + """ + raw = str(value or "/").strip() or "/" + storage = default_storage or StorageSchema.Local.value + for storage_schema in StorageSchema: + prefix = f"{storage_schema.value}:" + if raw.startswith(prefix): + storage = storage_schema.value + raw = raw[len(prefix):] or "/" + break + return storage, raw + + +def _is_path_below(root: Path, target: Path) -> bool: + """ + 判断本地目标是否位于配置根目录下,包含根目录本身。 + """ + try: + return target == root or target.is_relative_to(root) + except ValueError: + return False + + +def _is_agent_storage_path_allowed(fileitem: _SchemaFileItem) -> bool: + """ + 限制 Agent 文件浏览只能访问已配置的下载或媒体库目录。 + + 这是 Agent 的普通用户读取边界:管理端 ``/storage/list`` 仍由管理权限 + 保护,但 Agent 不能借助 ``path=/`` 让本地存储列出容器任意目录。所有 + 路径都按真实路径比较,以阻止通过符号链接逃逸本地配置根目录。 + """ + try: + requested_storage, requested_raw_path = _split_agent_storage_path( + fileitem.path, + fileitem.storage or StorageSchema.Local.value, + ) + configured_roots: list[tuple[str, str]] = [] + for directory in DirectoryHelper.get_dirs(): + for path_value, storage_value in ( + (directory.download_path, directory.storage), + (directory.library_path, directory.library_storage), + ): + if not path_value: + continue + root_storage, root_raw_path = _split_agent_storage_path( + path_value, + storage_value or StorageSchema.Local.value, + ) + configured_roots.append((root_storage, root_raw_path)) + + if requested_storage == StorageSchema.Local.value: + requested_path = Path(requested_raw_path).expanduser() + if not requested_path.is_absolute(): + return False + target = requested_path.resolve(strict=False) + for root_storage, root_raw_path in configured_roots: + root = Path(root_raw_path).expanduser() + if root_storage != StorageSchema.Local.value or not root.is_absolute(): + continue + if _is_path_below(root.resolve(strict=False), target): + return True + return False + + requested_path = posixpath.normpath(f"/{requested_raw_path.lstrip('/')}") + for root_storage, root_raw_path in configured_roots: + if root_storage != requested_storage: + continue + root_path = posixpath.normpath(f"/{root_raw_path.lstrip('/')}") + if requested_path == root_path or requested_path.startswith(f"{root_path.rstrip('/')}/"): + return True + return False + except (OSError, RuntimeError, TypeError, ValueError): + # 解析失败时拒绝访问,避免异常路径退化为全盘浏览。 + return False + + +def _is_safe_storage_child_name(name: Optional[str]) -> bool: + """ + 校验文件管理操作使用的单级子名称,拒绝路径分隔符和特殊目录名。 + """ + value = str(name or "").strip() + return ( + bool(value) + and value not in {".", ".."} + and "/" not in value + and "\\" not in value + and "\x00" not in value + ) + + +def _storage_child_item(fileitem: _SchemaFileItem, name: str) -> _SchemaFileItem: + """ + 按存储类型构造一个待校验的子路径文件项。 + """ + if (fileitem.storage or StorageSchema.Local.value) == StorageSchema.Local.value: + child_path = (Path(fileitem.path or "/") / name).as_posix() + else: + child_path = posixpath.join( + posixpath.normpath(f"/{(fileitem.path or '/').lstrip('/')}"), + name, + ) + return fileitem.model_copy(update={"path": child_path}) + + +def _ensure_manage_storage_path(fileitem: _SchemaFileItem, current_user: Any) -> None: + """ + 将非超级管理员的文件管理范围限制到已配置的下载或媒体库目录。 + + 超级管理员保留文件管理器的完整能力;普通管理用户不能借助文件管理 + 接口读取配置目录、删除任意容器路径或把重命名/建目录目标写到目录根外。 + """ + # 保留内部同步调用(例如批量重命名)不经过 FastAPI 注入时的兼容性; + # HTTP 路由始终会收到真实的 ApiPrincipal。 + if not hasattr(current_user, "is_superuser") or current_user.is_superuser: + return + if not _is_agent_storage_path_allowed(fileitem): + raise HTTPException(status_code=403, detail="管理用户只能操作已配置的存储目录") + + @router.post( "/agent/list", summary="查询 Agent 可用目录和文件", @@ -249,8 +375,10 @@ def list_agent_files( count: CompatibleCountParam = None, ) -> List[_SchemaFileItem]: """ - 保留旧 Agent 普通用户目录读取能力,不开放创建、改名或删除入口。 + 只允许 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) @@ -269,6 +397,10 @@ def mkdir( """ if not name: return _SchemaResponse(success=False) + if not _is_safe_storage_child_name(name): + return _SchemaResponse(success=False, message="目录名不能包含路径分隔符") + _ensure_manage_storage_path(fileitem, _) + _ensure_manage_storage_path(_storage_child_item(fileitem, name), _) result = StorageChain().create_folder(fileitem, name) if result: return _SchemaResponse(success=True) @@ -276,13 +408,17 @@ def mkdir( @router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None]) -def delete(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any: +def delete( + fileitem: _SchemaFileItem, + _: ApiPrincipal = Depends(get_current_active_manage_user), +) -> Any: """ 删除文件或目录 :param fileitem: 文件项 :param _: 依赖注入的已登录管理身份,仅用于权限校验 """ + _ensure_manage_storage_path(fileitem, _) result = StorageChain().delete_file(fileitem) if result: return _SchemaResponse(success=True) @@ -302,13 +438,17 @@ def delete(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_acti 404: {"model": _SchemaResponse[None], "description": "文件下载失败"}, }, ) -def download(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any: +def download( + fileitem: _SchemaFileItem, + _: ApiPrincipal = Depends(get_current_active_manage_user), +) -> Any: """ 下载文件或目录 :param fileitem: 文件项 :param _: 依赖注入的已登录管理身份,仅用于权限校验 """ + _ensure_manage_storage_path(fileitem, _) # 临时目录 tmp_file = StorageChain().download_file(fileitem) if tmp_file: @@ -328,13 +468,17 @@ def download(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_ac } }, ) -def image(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any: +def image( + fileitem: _SchemaFileItem, + _: ApiPrincipal = Depends(get_current_active_manage_user), +) -> Any: """ 读取存储中的图片并作为 JPEG 响应返回。 :param fileitem: 文件项 :param _: 依赖注入的已登录管理身份,仅用于权限校验 """ + _ensure_manage_storage_path(fileitem, _) # 临时目录 tmp_file = StorageChain().download_file(fileitem) if not tmp_file: @@ -359,6 +503,10 @@ def rename( """ if not new_name: 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), _) # 重命名目录内文件 if recursive: @@ -400,6 +548,7 @@ def rename( fileitem=sub_file, new_name=Path(new_path).name, recursive=False, + _=_, ) if not ret.success: progress.end() diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 5e95c0539..33d0ec529 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -20,6 +20,7 @@ from app.api.context import ( resolve_background_task_registry, ) from app.api.dependencies.auth import ( + get_current_active_manage_user_async, get_current_active_user, get_current_active_user_async, ) @@ -812,7 +813,10 @@ async def subscribe_share( @router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None]) -async def subscribe_share_delete(share_id: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def subscribe_share_delete( + share_id: int, + _: ApiPrincipal = Depends(get_current_active_manage_user_async), +) -> Any: """ 删除分享 """ @@ -844,7 +848,9 @@ async def subscribe_fork( @router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str]) async def followed_subscribers( - _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None + _: ApiPrincipal = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询已Follow的订阅分享人 @@ -853,7 +859,10 @@ async def followed_subscribers( @router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None]) -async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def follow_subscriber( + share_uid: Optional[str] = None, + _: ApiPrincipal = Depends(get_current_active_manage_user_async), +) -> Any: """ Follow订阅分享人 """ @@ -865,7 +874,10 @@ async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayl @router.delete("/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None]) -async def unfollow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def unfollow_subscriber( + share_uid: Optional[str] = None, + _: ApiPrincipal = Depends(get_current_active_manage_user_async), +) -> Any: """ 取消Follow订阅分享人 """ diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index d6a1ea959..b90effefd 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -557,7 +557,7 @@ async def query_queue_page( "/queue", summary="从整理队列中删除任务", response_model=_SchemaResponse[None] ) async def remove_queue( - fileitem: _SchemaFileItem, _: _SchemaTokenPayload = Depends(verify_token) + fileitem: _SchemaFileItem, _: Any = Depends(get_current_active_manage_user) ) -> Any: """ 查询整理队列 diff --git a/tests/test_agent_api_identity.py b/tests/test_agent_api_identity.py index 2fc935945..df42b831d 100644 --- a/tests/test_agent_api_identity.py +++ b/tests/test_agent_api_identity.py @@ -152,6 +152,25 @@ def test_public_allowlist_only_contains_audited_metadata_queries(): assert spec.confirmation is ConfirmationMode.NONE +@pytest.mark.parametrize( + "operation_id", + [ + "subscription.share.delete", + "subscription.follow.list", + "subscription.follow.add", + "subscription.follow.delete", + "transfer.queue.delete", + ], +) +def test_global_mutations_are_not_bound_user_operations(operation_id): + """ + 影响实例级状态的 Agent 操作必须要求系统管理员身份。 + """ + spec = resolve_api_operation(operation_id) + assert spec.identity_scope is ApiIdentityScope.ADMIN + assert spec.required_role is PrincipalRole.SYSTEM_ADMIN + + @pytest.mark.parametrize("changes", [ {"effect": ActionEffect.REVERSIBLE_WRITE}, {"effect": ActionEffect.SENSITIVE_READ}, diff --git a/tests/test_agent_api_projection_endpoints.py b/tests/test_agent_api_projection_endpoints.py index 60238eecc..0d086f444 100644 --- a/tests/test_agent_api_projection_endpoints.py +++ b/tests/test_agent_api_projection_endpoints.py @@ -293,9 +293,23 @@ def test_storage_agent_list_reuses_bounded_filter_and_sort(monkeypatch) -> None: ] monkeypatch.setattr(storage_endpoint, "StorageChain", _StorageChain) + monkeypatch.setattr( + storage_endpoint.DirectoryHelper, + "get_dirs", + staticmethod( + lambda: [ + SimpleNamespace( + storage="local", + download_path="/media", + library_storage="local", + library_path=None, + ) + ] + ), + ) result = storage_endpoint.list_agent_files( - fileitem=FileItem(path="/"), + fileitem=FileItem(path="/media"), sort="name", keyword="Episode*", _=object(), diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 7d82ca1d9..ccf87786f 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -12,7 +12,7 @@ from fastapi import HTTPException, UploadFile from starlette.requests import Request from starlette.responses import Response -from app.adapters.web.security.access import verify_token +from app.adapters.web.security.access import verify_resource_token, verify_token from app.api.deps import ( get_current_active_manage_user, get_current_active_manage_user_async, @@ -22,6 +22,7 @@ from app.api.deps import ( get_current_active_user_async, ) from app.api.endpoints import dashboard as dashboard_endpoint +from app.api.endpoints import agent as agent_endpoint from app.api.endpoints import github as github_endpoint from app.api.endpoints import history as history_endpoint from app.api.endpoints import login as login_endpoint @@ -31,6 +32,7 @@ from app.api.endpoints import plugin as plugin_endpoint from app.api.endpoints import rule as rule_endpoint from app.api.endpoints import site as site_endpoint from app.api.endpoints import storage as storage_endpoint +from app.api.endpoints import subscribe as subscribe_endpoint from app.api.endpoints import system as system_endpoint from app.api.endpoints import transfer as transfer_endpoint from app.api.endpoints import user as user_endpoint @@ -193,6 +195,20 @@ def test_webpush_send_requires_superuser_and_subscribe_accepts_login_user(): assert _dependency_of(message_endpoint.subscribe, "_") is verify_token +def test_global_mutations_and_agent_file_access_keep_privilege_boundaries(): + """ + 全局状态修改和临时附件下载不能退化为普通登录或匿名访问。 + """ + assert _dependency_of(subscribe_endpoint.subscribe_share_delete, "_") is get_current_active_manage_user_async + assert _dependency_of(subscribe_endpoint.followed_subscribers, "_") is get_current_active_manage_user_async + assert _dependency_of(subscribe_endpoint.follow_subscriber, "_") is get_current_active_manage_user_async + assert _dependency_of(subscribe_endpoint.unfollow_subscriber, "_") is get_current_active_manage_user_async + assert _dependency_of(transfer_endpoint.remove_queue, "_") is get_current_active_manage_user + assert _dependency_of(history_endpoint.delete_download_history, "_") is get_current_active_manage_user + assert _dependency_of(message_endpoint.clear_notification_message, "_") is get_current_active_superuser + assert _dependency_of(agent_endpoint.download_web_agent_file, "_") is verify_resource_token + + def test_manage_page_endpoints_accept_manage_permission(): """ 管理页面接口允许具备 manage 权限的普通用户访问。 diff --git a/tests/test_storage_agent_security.py b/tests/test_storage_agent_security.py new file mode 100644 index 000000000..328d07f10 --- /dev/null +++ b/tests/test_storage_agent_security.py @@ -0,0 +1,117 @@ +""" +验证 Agent 存储浏览只能落在已配置的目录根下。 +""" + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api.endpoints import storage as storage_endpoint +from app.schemas.file import FileItem + + +def _directory(*, download_path="/media/downloads", storage="local", library_path=None, library_storage=None): + """ + 构造最小的目录配置桩。 + """ + return SimpleNamespace( + download_path=download_path, + storage=storage, + library_path=library_path, + library_storage=library_storage, + ) + + +def test_agent_storage_path_allowlist_rejects_root_and_parent_escape(monkeypatch, tmp_path): + """ + 根目录、配置根外路径和符号链接逃逸都必须被拒绝。 + """ + media_root = tmp_path / "media" + download_root = media_root / "downloads" + secret_root = tmp_path / "secret" + download_root.mkdir(parents=True) + secret_root.mkdir() + link = download_root / "escape" + link.symlink_to(secret_root, target_is_directory=True) + monkeypatch.setattr( + storage_endpoint.DirectoryHelper, + "get_dirs", + staticmethod(lambda: [_directory(download_path=str(download_root))]), + ) + + assert storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root))) + assert storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root / "movie"))) + assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(media_root))) + assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root / ".." / "secret"))) + assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(link))) + + +def test_agent_storage_remote_path_uses_configured_storage_root(monkeypatch): + """ + 远程存储按存储标识和配置根比较,不能跨存储或穿越根目录。 + """ + monkeypatch.setattr( + storage_endpoint.DirectoryHelper, + "get_dirs", + staticmethod(lambda: [_directory(download_path="/library", storage="u115")]), + ) + + assert storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/library")) + assert storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/library/movie")) + assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/other")) + assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="alipan", path="/library")) + + +def test_agent_storage_endpoint_fails_closed_before_provider_call(monkeypatch, tmp_path): + """ + 不在配置范围内时直接返回 403,不触发底层存储访问。 + """ + called = False + + class _StorageChain: + def list_files(self, _fileitem): + nonlocal called + called = True + return [] + + monkeypatch.setattr(storage_endpoint.StorageChain, "list_files", _StorageChain().list_files) + monkeypatch.setattr( + storage_endpoint.DirectoryHelper, + "get_dirs", + staticmethod(lambda: [_directory(download_path=str(tmp_path / "allowed"))]), + ) + + with pytest.raises(HTTPException) as exc_info: + storage_endpoint.list_agent_files(fileitem=FileItem(path="/"), _=object()) + + assert exc_info.value.status_code == 403 + assert called is False + + +def test_manage_storage_guard_blocks_non_superuser_outside_configured_roots(monkeypatch, tmp_path): + """ + 非超级管理员不能借助文件管理器读写配置目录或构造穿越目标。 + """ + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setattr( + storage_endpoint.DirectoryHelper, + "get_dirs", + staticmethod(lambda: [_directory(download_path=str(allowed))]), + ) + manage_user = SimpleNamespace(is_superuser=False) + superuser = SimpleNamespace(is_superuser=True) + + storage_endpoint._ensure_manage_storage_path(FileItem(path=str(allowed)), manage_user) + storage_endpoint._ensure_manage_storage_path(FileItem(path="/config"), superuser) + assert storage_endpoint._is_safe_storage_child_name("movie") + assert not storage_endpoint._is_safe_storage_child_name("../config") + assert not storage_endpoint._is_safe_storage_child_name("movie/secret") + + with pytest.raises(HTTPException) as exc_info: + storage_endpoint._ensure_manage_storage_path(FileItem(path="/config"), manage_user) + assert exc_info.value.status_code == 403 + + escaped = storage_endpoint._storage_child_item(FileItem(path=str(allowed)), "../config") + assert not storage_endpoint._is_agent_storage_path_allowed(escaped) From 6f11557eea7496df713ec04f2a43db9daff3fe00 Mon Sep 17 00:00:00 2001 From: ranzhigg Date: Fri, 30 Oct 2026 16:26:44 +0800 Subject: [PATCH 2/4] fix: satisfy authorization PR checks --- app/api/endpoints/storage.py | 8 +++++--- app/locales/en-US.json | 4 ++++ skills/moviepilot-api/api/subscription.md | 16 ++++++++-------- skills/moviepilot-api/api/transfer.md | 4 ++-- tests/test_agent_api_identity.py | 1 - 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 284599953..67a9faf45 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -304,12 +304,14 @@ def _is_agent_storage_path_allowed(fileitem: _SchemaFileItem) -> bool: return True return False - requested_path = posixpath.normpath(f"/{requested_raw_path.lstrip('/')}") + requested_remote_path = posixpath.normpath(f"/{requested_raw_path.lstrip('/')}") for root_storage, root_raw_path in configured_roots: if root_storage != requested_storage: continue - root_path = posixpath.normpath(f"/{root_raw_path.lstrip('/')}") - if requested_path == root_path or requested_path.startswith(f"{root_path.rstrip('/')}/"): + root_remote_path = posixpath.normpath(f"/{root_raw_path.lstrip('/')}") + if requested_remote_path == root_remote_path or requested_remote_path.startswith( + f"{root_remote_path.rstrip('/')}/" + ): return True return False except (OSError, RuntimeError, TypeError, ValueError): diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 00e465ba5..96b7e45d4 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -591,6 +591,10 @@ "订阅活动暂时不可用": "Subscription activity is temporarily unavailable", "活动不存在或无权查看": "Activity does not exist or access is denied", "文件路径为空": "File path is empty", + "目录名不能包含路径分隔符": "Directory names cannot contain path separators", + "名称不能包含路径分隔符": "Names cannot contain path separators", + "管理用户只能操作已配置的存储目录": "Management users can only operate within configured storage directories", + "Agent 只能浏览已配置的存储目录": "Agents can only browse configured storage directories", "源文件路径为空": "Source file path is empty", "未识别到媒体类型": "Media type could not be identified", "站点域名未配置": "Site domain is not configured", diff --git a/skills/moviepilot-api/api/subscription.md b/skills/moviepilot-api/api/subscription.md index a7d6aaea8..a311f52a6 100644 --- a/skills/moviepilot-api/api/subscription.md +++ b/skills/moviepilot-api/api/subscription.md @@ -71,24 +71,24 @@ Identity scope: `bound_user`; required role: `user`. ### `subscription.follow.add` `POST /api/v1/subscribe/follow`; policy effect: `reversible_write`. -Purpose: 按精确分享用户标识关注一个订阅分享用户。 -Identity scope: `bound_user`; required role: `user`. +Purpose: 修改实例级关注的订阅分享用户列表。 +Identity scope: `admin`; required role: `system_admin`. - `path_params`: none - `query`: `share_uid` (string|null): 需要关注或取消关注的精确 MoviePilot Server 分享用户标识。 - `body`: none ### `subscription.follow.delete` `DELETE /api/v1/subscribe/follow`; policy effect: `reversible_write`. -Purpose: 按精确分享用户标识取消关注一个订阅分享用户。 -Identity scope: `bound_user`; required role: `user`. +Purpose: 从实例级关注列表移除一个订阅分享用户。 +Identity scope: `admin`; required role: `system_admin`. - `path_params`: none - `query`: `share_uid` (string|null): 需要关注或取消关注的精确 MoviePilot Server 分享用户标识。 - `body`: none ### `subscription.follow.list` `GET /api/v1/subscribe/follow`; policy effect: `safe_read`. -Purpose: 列出当前用户关注的订阅分享用户标识。 -Identity scope: `bound_user`; required role: `user`. +Purpose: 读取实例级关注的订阅分享用户列表。 +Identity scope: `admin`; required role: `system_admin`. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: none - `query`: `count` (integer|null): 旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。; `page` (integer|null): 旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。 @@ -198,8 +198,8 @@ Identity scope: `bound_user`; required role: `user`. ### `subscription.share.delete` `DELETE /api/v1/subscribe/share/{share_id}`; policy effect: `external_side_effect`. -Purpose: 按分享标识删除一条共享订阅发布记录。 -Identity scope: `bound_user`; required role: `user`. +Purpose: 按分享标识删除一条实例级共享订阅发布记录。 +Identity scope: `admin`; required role: `system_admin`. - `path_params`: `share_id*` (integer): 分享列表操作返回的持久化 MoviePilot Server 分享标识。 - `query`: none - `body`: none diff --git a/skills/moviepilot-api/api/transfer.md b/skills/moviepilot-api/api/transfer.md index c8c774117..bf03ceda2 100644 --- a/skills/moviepilot-api/api/transfer.md +++ b/skills/moviepilot-api/api/transfer.md @@ -160,8 +160,8 @@ Identity scope: `bound_user`; required role: `user`. ### `transfer.queue.delete` `DELETE /api/v1/transfer/queue`; policy effect: `destructive_write`. -Purpose: 从整理队列移除一个精确存储项目,并停止其整理。 -Identity scope: `bound_user`; required role: `user`. +Purpose: 从实例整理队列移除一个精确存储项目,并停止其整理。 +Identity scope: `admin`; required role: `system_admin`. - `path_params`: none - `query`: none - `body`: `basename` (string|null): 不含扩展名的文件基本名称; `children` (array|null): 子节点; `drive_id` (string|null): 云盘驱动器标识; `extension` (string|null): 文件后缀; `fileid` (string|null): 存储服务分配的文件标识; `modify_time` (number|null): 文件修改时间的 Unix 时间戳; `name` (string|null): 文件名; `parent_fileid` (string|null): 父ID; `path` (string|null; default `/`): 文件路径; `pickcode` (string|null): 115 文件下载提取码; `size` (integer|null): 文件大小; `storage` (string|null; default `local`): 存储类型,默认 local 表示本地存储; `thumbnail` (string|null): 缩略图; `type` (string|null): 条目类型,dir 表示目录,file 表示文件; `url` (string|null): 访问地址 diff --git a/tests/test_agent_api_identity.py b/tests/test_agent_api_identity.py index df42b831d..0fd7ad7e0 100644 --- a/tests/test_agent_api_identity.py +++ b/tests/test_agent_api_identity.py @@ -99,7 +99,6 @@ def test_public_queries_do_not_require_channel_binding(operation_id, arguments, ("storage.list", {"body": {"path": "/", "storage": "local", "type": "dir"}}), ("config.user.get", {}), ("config.public.get", {"path_params": {"key": "Storages"}}), - ("subscription.follow.list", {}), ]) @pytest.mark.parametrize("user_state", ["unbound", "inactive"]) def test_instance_and_user_operations_still_require_binding(operation_id, arguments, user_state): From f538c7276b8857ca944a4ce68152e401c5f65d45 Mon Sep 17 00:00:00 2001 From: ranzhigg Date: Fri, 30 Oct 2026 16:33:03 +0800 Subject: [PATCH 3/4] style: sort authorization test imports --- tests/test_api_authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index ccf87786f..349dcd234 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -21,8 +21,8 @@ from app.api.deps import ( get_current_active_user, get_current_active_user_async, ) -from app.api.endpoints import dashboard as dashboard_endpoint from app.api.endpoints import agent as agent_endpoint +from app.api.endpoints import dashboard as dashboard_endpoint from app.api.endpoints import github as github_endpoint from app.api.endpoints import history as history_endpoint from app.api.endpoints import login as login_endpoint From 80a76dee5656d5ce12dbbf249c390ec0633219a3 Mon Sep 17 00:00:00 2001 From: ranzhigg Date: Fri, 30 Oct 2026 17:37:14 +0800 Subject: [PATCH 4/4] fix(storage): bind management operations to verified targets --- app/api/endpoints/storage.py | 107 +++++++++++++++++-- app/locales/en-US.json | 2 + tests/test_agent_api_projection_endpoints.py | 6 ++ tests/test_storage_agent_security.py | 100 +++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) 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