mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
fix: tighten API authorization boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": "根据完整定义创建一个工作流。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 智能助手本轮生成的临时附件。
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
删除下载历史记录
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
记录通知中心清理时间,后续通知历史查询会在服务端过滤。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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订阅分享人
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
查询整理队列
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 权限的普通用户访问。
|
||||
|
||||
117
tests/test_storage_agent_security.py
Normal file
117
tests/test_storage_agent_security.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user