fix: restrict sensitive system endpoints

This commit is contained in:
jxxghp
2026-06-09 21:45:51 +08:00
parent d1cf584af9
commit dc2b6910a4
10 changed files with 476 additions and 78 deletions

View File

@@ -7,9 +7,10 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.core.security import verify_token, verify_apitoken
from app.core.security import verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.helper.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.utils.system import SystemUtils
@@ -17,12 +18,9 @@ from app.utils.system import SystemUtils
router = APIRouter()
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
"""
查询媒体数量统计信息
构建媒体数量统计信息
"""
media_statistics: Optional[List[schemas.Statistic]] = (
DashboardChain().media_statistic(name)
@@ -42,24 +40,12 @@ def statistic(
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
ret_statistic.episode_count = None
return ret_statistic
else:
return schemas.Statistic()
return schemas.Statistic()
@router.get(
"/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic
)
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def _build_storage() -> schemas.Storage:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return statistic()
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询本地存储空间信息
构建本地存储空间信息。
"""
total, available = 0, 0
dirs = DirectoryHelper().get_dirs()
@@ -74,30 +60,9 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return schemas.Storage(total_storage=total, used_storage=total - available)
@router.get(
"/storage2", summary="本地存储空间API_TOKEN", response_model=schemas.Storage
)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
"""
查询本地存储空间信息 API_TOKEN认证?token=xxx
"""
return storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询进程信息
"""
return SystemUtils.processes()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
查询下载器信息
构建下载器统计信息。
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_local_download_dirs()
@@ -117,6 +82,62 @@ def downloader(
return downloader_info
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
) -> Any:
"""
查询媒体数量统计信息
"""
return _build_statistic(name)
@router.get(
"/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic
)
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return _build_statistic()
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
def storage(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询本地存储空间信息
"""
return _build_storage()
@router.get(
"/storage2", summary="本地存储空间API_TOKEN", response_model=schemas.Storage
)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询本地存储空间信息 API_TOKEN认证?token=xxx
"""
return _build_storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询进程信息
"""
return SystemUtils.processes()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
) -> Any:
"""
查询下载器信息
"""
return _build_downloader(name)
@router.get(
"/downloader2",
summary="下载器信息API_TOKEN",
@@ -126,11 +147,11 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return downloader()
return _build_downloader()
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询后台服务信息
"""
@@ -146,14 +167,14 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return await schedule()
return Scheduler().list()
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
async def transfer(
days: Optional[int] = 7,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: Any = Depends(get_current_active_superuser),
) -> Any:
"""
查询文件整理统计信息
@@ -163,7 +184,7 @@ async def transfer(
@router.get("/cpu", summary="获取当前CPU使用率", response_model=float)
def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def cpu(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前CPU使用率
"""
@@ -175,11 +196,11 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前CPU使用率 API_TOKEN认证?token=xxx
"""
return cpu()
return SystemUtils.cpu_usage()
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前内存使用率
"""
@@ -195,11 +216,11 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前内存使用率 API_TOKEN认证?token=xxx
"""
return memory()
return SystemUtils.memory_usage()
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def network(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前网络流量上行和下行流量单位bytes/s
"""
@@ -213,4 +234,4 @@ def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前网络流量 API_TOKEN认证?token=xxx
"""
return network()
return SystemUtils.network_usage()

View File

@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import Any, List, Annotated
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from app import schemas
@@ -18,6 +18,8 @@ router = APIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
def login_access_token(
request: Request,
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
otp_password: Annotated[str | None, Form()] = None,
) -> Any:
@@ -45,14 +47,27 @@ def login_access_token(
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
)
return schemas.Token(
access_token=security.create_access_token(
userid=user_or_message.id,
access_token = security.create_access_token(
userid=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
)
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
sub=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
purpose="authentication",
),
)
return schemas.Token(
access_token=access_token,
token_type="bearer",
super_user=user_or_message.is_superuser,
user_id=user_or_message.id,

View File

@@ -7,7 +7,7 @@ from datetime import timedelta
from typing import Any, Annotated, Optional
from app.helper.sites import SitesHelper
from fastapi import APIRouter, Depends, HTTPException, Body
from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
@@ -356,7 +356,9 @@ def passkey_authenticate_start(
summary="完成 PassKey 认证",
response_model=schemas.Token,
)
def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any:
def passkey_authenticate_finish(
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
) -> Any:
"""完成 PassKey 认证 - 验证凭证并返回 token"""
try:
# 提取并标准化凭证ID
@@ -393,14 +395,27 @@ def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any
and not settings.ADVANCED_MODE
)
return schemas.Token(
access_token=security.create_access_token(
userid=user.id,
access_token = security.create_access_token(
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
)
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
purpose="authentication",
),
)
return schemas.Token(
access_token=access_token,
token_type="bearer",
super_user=user.is_superuser,
user_id=user.id,

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Any, List, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Security
from fastapi.concurrency import run_in_threadpool
from starlette import status
from starlette.responses import StreamingResponse
@@ -13,7 +13,12 @@ from app import schemas
from app.command import Command
from app.core.config import settings
from app.core.plugin import PluginManager
from app.core.security import verify_apikey, verify_token
from app.core.security import (
resource_token_cookie,
verify_apikey,
verify_resource_token,
verify_token,
)
from app.db.models import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
@@ -163,6 +168,48 @@ def _merge_plugin_market_metadata(
return plugin
def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool:
"""
判断静态文件是否属于插件声明的匿名登录认证远程组件。
登录页加载插件认证组件时尚未产生登录态和资源 Cookie因此仅对插件主动
声明的认证 remote 保留匿名读取能力,其余插件静态资源仍需资源令牌。
"""
path = filepath.lstrip("/")
normalized_plugin_id = plugin_id.lower()
plugin_manager = PluginManager()
for provider in plugin_manager.get_plugin_auth_providers():
remote = provider.get("remote") or {}
if str(remote.get("id") or "").lower() != normalized_plugin_id:
continue
remote_path = str(remote.get("url") or "").lstrip("/")
remote_path_lower = remote_path.lower()
expected_prefix = f"plugin/file/{normalized_plugin_id}/"
if not remote_path_lower.startswith(expected_prefix):
continue
remote_file = remote_path[len(expected_prefix):]
remote_dir = remote_file.rsplit("/", 1)[0] if "/" in remote_file else ""
if path == remote_file or (remote_dir and path.startswith(f"{remote_dir}/")):
return True
return False
def _verify_plugin_static_file_access(
plugin_id: str,
filepath: str,
resource_token: Annotated[Optional[str], Security(resource_token_cookie)] = None,
) -> None:
"""
校验插件静态文件访问权限。
普通插件资源依赖登录后写入的资源 Cookie登录认证插件的远程组件需要在
登录前加载,因此仅对插件声明的认证 remote 放行匿名读取。
"""
if _is_plugin_auth_remote_file(plugin_id, filepath):
return
verify_resource_token(resource_token)
async def _get_plugin_history_detail(
plugin_id: str, force: bool = True
) -> Optional[schemas.Plugin]:
@@ -440,7 +487,7 @@ def plugin_page(
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
def plugin_dashboard_meta(
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> List[dict]:
"""
获取所有插件仪表板元信息
@@ -453,7 +500,7 @@ def plugin_dashboard_by_key(
plugin_id: str,
key: str,
user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> Optional[schemas.PluginDashboard]:
"""
根据插件ID获取插件仪表板
@@ -465,7 +512,7 @@ def plugin_dashboard_by_key(
def plugin_dashboard(
plugin_id: str,
user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
@@ -493,7 +540,11 @@ def reset_plugin(
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
async def plugin_static_file(plugin_id: str, filepath: str):
async def plugin_static_file(
plugin_id: str,
filepath: str,
_: None = Depends(_verify_plugin_static_file_access),
) -> StreamingResponse:
"""
获取插件静态文件
"""

View File

@@ -49,6 +49,19 @@ from version import APP_VERSION
router = APIRouter()
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_PUBLIC_SYSTEM_CONFIG_KEYS = {
item.value: item
for item in (
SystemConfigKey.Directories,
SystemConfigKey.Storages,
SystemConfigKey.IndexerSites,
SystemConfigKey.EpisodeFormatRuleTable,
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.FollowSubscribers,
)
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -501,7 +514,9 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
async def get_env_setting(_: User = Depends(get_current_active_user_async)):
async def get_env_setting(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""
查询系统环境变量,包括当前版本号(仅管理员)
"""
@@ -527,6 +542,14 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response)
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
"""
检测服务是否可用
"""
return schemas.Response(success=True)
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
async def set_env_setting(
env: dict, _: User = Depends(get_current_active_superuser_async)
@@ -587,8 +610,25 @@ async def get_progress(
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response)
async def get_public_setting(
key: str, _: User = Depends(get_current_active_user_async)
) -> schemas.Response:
"""
查询普通用户可读取的非敏感系统设置
"""
if key in _PUBLIC_SETTINGS_KEYS:
return schemas.Response(success=True, data={"value": getattr(settings, key)})
if key not in _PUBLIC_SYSTEM_CONFIG_KEYS:
raise HTTPException(status_code=404, detail="配置项不存在")
value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key])
return schemas.Response(success=True, data={"value": value})
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
async def get_setting(key: str, _: User = Depends(get_current_active_user_async)):
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)
) -> schemas.Response:
"""
查询系统设置(仅管理员)
"""

View File

@@ -104,11 +104,14 @@ async def upload_avatar(
user_id: int,
db: AsyncSession = Depends(get_async_db),
file: UploadFile = File(...),
_: User = Depends(get_current_active_user_async),
):
current_user: User = Depends(get_current_active_user_async),
) -> schemas.Response:
"""
上传用户头像
"""
if current_user.id != user_id and not current_user.is_superuser:
raise HTTPException(status_code=400, detail="用户权限不足")
# 将文件转换为Base64
file_base64 = base64.b64encode(file.file.read())
# 更新到用户表