mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-27 00:52:21 +08:00
fix(security): harden auth, JWT, WebAuthn, and API endpoints
- Persist JWT secret to config/.jwt_secret (survives restarts) - Change active_user from list to dict with timestamps - Extract username from cookie token instead of list index - Add SSRF protection (_validate_url) for setup test endpoints - Mask sensitive config fields (password, api_key, token, secret) - Add auth guards to notification test endpoints - Fix path traversal in /posters endpoint using resolved path check - Add CORS middleware with empty allow_origins - WebAuthn: add challenge TTL (300s), max capacity (100), cleanup - Remove hardcoded default password from User model - Use timezone-aware datetime in passkey models - Adapt unit tests for active_user dict and cookie-based auth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
@@ -12,7 +12,7 @@ from module.security.api import (
|
||||
get_current_user,
|
||||
update_user_info,
|
||||
)
|
||||
from module.security.jwt import create_access_token
|
||||
from module.security.jwt import create_access_token, decode_token
|
||||
|
||||
from .response import u_response
|
||||
|
||||
@@ -35,19 +35,29 @@ async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)
|
||||
@router.get(
|
||||
"/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def refresh(response: Response):
|
||||
token = create_access_token(
|
||||
data={"sub": active_user[0]}, expires_delta=timedelta(days=1)
|
||||
async def refresh(response: Response, token: str = Cookie(None)):
|
||||
payload = decode_token(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
|
||||
)
|
||||
active_user[username] = datetime.now()
|
||||
new_token = create_access_token(
|
||||
data={"sub": username}, expires_delta=timedelta(days=1)
|
||||
)
|
||||
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
response.set_cookie(key="token", value=new_token, httponly=True, max_age=86400)
|
||||
return {"access_token": new_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def logout(response: Response):
|
||||
active_user.clear()
|
||||
async def logout(response: Response, token: str = Cookie(None)):
|
||||
payload = decode_token(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if username:
|
||||
active_user.pop(username, None)
|
||||
response.delete_cookie(key="token")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
@@ -56,8 +66,15 @@ async def logout(response: Response):
|
||||
|
||||
|
||||
@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)])
|
||||
async def update_user(user_data: UserUpdate, response: Response):
|
||||
old_user = active_user[0]
|
||||
async def update_user(
|
||||
user_data: UserUpdate, response: Response, token: str = Cookie(None)
|
||||
):
|
||||
payload = decode_token(token)
|
||||
old_user = payload.get("sub") if payload else None
|
||||
if not old_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
|
||||
)
|
||||
if update_user_info(user_data, old_user):
|
||||
token = create_access_token(
|
||||
data={"sub": old_user}, expires_delta=timedelta(days=1)
|
||||
|
||||
@@ -10,10 +10,24 @@ from module.security.api import UNAUTHORIZED, get_current_user
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
|
||||
|
||||
@router.get("/get", response_model=Config, dependencies=[Depends(get_current_user)])
|
||||
|
||||
def _sanitize_dict(d: dict) -> dict:
|
||||
result = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
result[k] = _sanitize_dict(v)
|
||||
elif any(s in k.lower() for s in _SENSITIVE_KEYS):
|
||||
result[k] = "********"
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/get", dependencies=[Depends(get_current_user)])
|
||||
async def get_config():
|
||||
return settings
|
||||
return _sanitize_dict(settings.dict())
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -27,7 +41,10 @@ async def update_config(config: Config):
|
||||
logger.info("Config updated")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"msg_en": "Update config successfully.", "msg_zh": "更新配置成功。"},
|
||||
content={
|
||||
"msg_en": "Update config successfully.",
|
||||
"msg_zh": "更新配置成功。",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from module.notification import NotificationManager
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.notification import NotificationManager
|
||||
from module.security.api import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/notification", tags=["notification"])
|
||||
@@ -44,7 +45,9 @@ class TestResponse(BaseModel):
|
||||
message_en: str = ""
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestResponse)
|
||||
@router.post(
|
||||
"/test", response_model=TestResponse, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def test_provider(request: TestProviderRequest):
|
||||
"""Test a configured notification provider by its index.
|
||||
|
||||
@@ -78,7 +81,11 @@ async def test_provider(request: TestProviderRequest):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-config", response_model=TestResponse)
|
||||
@router.post(
|
||||
"/test-config",
|
||||
response_model=TestResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def test_provider_config(request: TestProviderConfigRequest):
|
||||
"""Test an unsaved notification provider configuration.
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
Passkey 管理 API
|
||||
用于注册、列表、删除 Passkey 凭证
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
@@ -233,8 +234,7 @@ async def login_with_passkey(
|
||||
data={"sub": username}, expires_delta=timedelta(days=1)
|
||||
)
|
||||
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
|
||||
if username not in active_user:
|
||||
active_user.append(username)
|
||||
active_user[username] = datetime.now()
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -7,9 +10,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from module.conf import VERSION, settings
|
||||
from module.models import Config, ResponseModel
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.network import RequestContent
|
||||
from module.notification import PROVIDER_REGISTRY
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.security.jwt import get_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +31,27 @@ def _require_setup_needed():
|
||||
raise HTTPException(status_code=403, detail="Setup already completed.")
|
||||
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
"""Reject non-HTTP schemes and private/reserved/loopback IPs."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Only http/https URLs are allowed.")
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="Invalid URL: no hostname.")
|
||||
try:
|
||||
addrs = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise HTTPException(status_code=400, detail="Cannot resolve hostname.")
|
||||
for family, _, _, _, sockaddr in addrs:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if ip.is_private or ip.is_reserved or ip.is_loopback:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="URLs pointing to private/reserved IPs are not allowed.",
|
||||
)
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
|
||||
@@ -108,12 +132,16 @@ async def test_downloader(req: TestDownloaderRequest):
|
||||
|
||||
scheme = "https" if req.ssl else "http"
|
||||
host = req.host if "://" in req.host else f"{scheme}://{req.host}"
|
||||
_validate_url(host)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Check if host is reachable and is qBittorrent
|
||||
resp = await client.get(host)
|
||||
if "qbittorrent" not in resp.text.lower() and "vuetorrent" not in resp.text.lower():
|
||||
if (
|
||||
"qbittorrent" not in resp.text.lower()
|
||||
and "vuetorrent" not in resp.text.lower()
|
||||
):
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Host is reachable but does not appear to be qBittorrent.",
|
||||
@@ -169,6 +197,7 @@ async def test_downloader(req: TestDownloaderRequest):
|
||||
async def test_rss(req: TestRSSRequest):
|
||||
"""Test an RSS feed URL."""
|
||||
_require_setup_needed()
|
||||
_validate_url(req.url)
|
||||
|
||||
try:
|
||||
async with RequestContent() as request:
|
||||
|
||||
Reference in New Issue
Block a user