mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-24 07:31:02 +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,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