fix: tighten API authorization boundaries

This commit is contained in:
ranzhigg
2026-10-30 16:14:49 +08:00
parent 9aab7c0f4c
commit 914abf6273
13 changed files with 360 additions and 30 deletions

View File

@@ -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,

View File

@@ -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": "根据完整定义创建一个工作流。",

View File

@@ -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",

View File

@@ -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 智能助手本轮生成的临时附件。

View File

@@ -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:
"""
删除下载历史记录

View File

@@ -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]]:
"""
记录通知中心清理时间,后续通知历史查询会在服务端过滤。

View File

@@ -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()

View File

@@ -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订阅分享人
"""

View File

@@ -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:
"""
查询整理队列

View File

@@ -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},

View File

@@ -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(),

View File

@@ -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 权限的普通用户访问。

View 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)