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)