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:
Estrella Pan
2026-02-23 11:46:12 +01:00
parent 339166508b
commit c7c709fa66
16 changed files with 284 additions and 148 deletions

View File

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

View File

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

View File

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

View File

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

View File

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