fix: 审计7个FINDING修复
- F-1 [MEDIUM] IpAllowlistSaveRequest 加 IP/CIDR/域名格式校验 - F-2 [MEDIUM] _is_private_host 改为 async DNS 解析 (不再阻塞事件循环) - F-3 [LOW] hot-reload int转换失败改 logger.warning 而非静默pass - F-4 [LOW] 提取 _verify_reauth 辅助函数,消除4处重复re-auth代码 - F-5 [LOW] ChangePasswordRequest.totp_code 加 min_length=6 约束 - F-6 [LOW] revealTelegramToken 空catch改 console.error+toast - F-7 [LOW] toggleApiKeyVisibility 空catch改 console.error+toast
This commit is contained in:
+36
-45
@@ -7,6 +7,7 @@ Sensitive values are masked in GET responses.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
@@ -58,7 +59,7 @@ SETTING_VALIDATORS: dict[str, tuple] = {
|
||||
|
||||
|
||||
# S-01: SSRF protection — check if hostname resolves to private/link-local IP
|
||||
def _is_private_host(hostname: str) -> bool:
|
||||
async def _is_private_host(hostname: str) -> bool:
|
||||
"""Check if hostname resolves to any private/loopback/link-local IP."""
|
||||
if not hostname:
|
||||
return True
|
||||
@@ -68,9 +69,10 @@ def _is_private_host(hostname: str) -> bool:
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
||||
except ValueError:
|
||||
pass # Not a literal IP, do DNS resolution
|
||||
# DNS resolution check (blocks DNS rebinding too)
|
||||
# F-2: Async DNS resolution (non-blocking)
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
|
||||
loop = asyncio.get_event_loop()
|
||||
infos = await loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
|
||||
for _family, _, _, _, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
@@ -82,6 +84,15 @@ def _is_private_host(hostname: str) -> bool:
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
async def _verify_reauth(db: AsyncSession, admin: Admin, password: str) -> None:
|
||||
"""F-4: Shared re-auth helper for reveal endpoints. Raises 400 on failure."""
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(password.encode(), current.password_hash.encode()):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
|
||||
# ── System Settings ──
|
||||
|
||||
@router.get("/", response_model=list)
|
||||
@@ -144,15 +155,7 @@ async def reveal_api_key(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal global API_KEY after re-entering account password."""
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("api_key")
|
||||
@@ -203,6 +206,8 @@ async def set_setting(
|
||||
setting = await repo.set(key, str(payload.value))
|
||||
|
||||
# Hot-reload: update in-memory settings so changes take effect immediately
|
||||
import logging
|
||||
_logger = logging.getLogger("nexus.settings")
|
||||
from server.config import settings as _settings
|
||||
attr_name = _settings.DB_OVERRIDE_MAP.get(key)
|
||||
if attr_name and hasattr(_settings, attr_name):
|
||||
@@ -211,8 +216,10 @@ async def set_setting(
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
setattr(_settings, attr_name, value)
|
||||
_logger.warning("hot-reload: %s 值 '%s' 无法转为 int,已跳过热更新", attr_name, value)
|
||||
value = None # Skip setattr for invalid int
|
||||
if value is not None:
|
||||
setattr(_settings, attr_name, value)
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -413,16 +420,7 @@ async def reveal_preset(
|
||||
):
|
||||
"""Reveal a password preset's decrypted value (re-auth + audit-logged)."""
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
# Re-authenticate: require current password (same as reveal_api_key)
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
@@ -558,16 +556,7 @@ async def reveal_ssh_key_preset(
|
||||
):
|
||||
"""Reveal an SSH key preset's decrypted private key (re-auth + audit-logged)."""
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
# Re-authenticate: require current password
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
@@ -773,15 +762,7 @@ async def reveal_telegram_token(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal Telegram Bot Token after re-entering account password."""
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("telegram_bot_token")
|
||||
@@ -898,7 +879,7 @@ async def parse_subscription(
|
||||
|
||||
# S-01: SSRF protection — block private/link-local/loopback IPs
|
||||
hostname = parsed.hostname or ""
|
||||
if _is_private_host(hostname):
|
||||
if await _is_private_host(hostname):
|
||||
raise HTTPException(status_code=400, detail="不允许访问内网地址")
|
||||
|
||||
MAX_BODY = 1_000_000 # 1 MB
|
||||
@@ -917,7 +898,7 @@ async def parse_subscription(
|
||||
redirect_url = str(httpx.URL(location).resolve_with(url))
|
||||
redir_parsed = urlparse(redirect_url)
|
||||
redir_host = redir_parsed.hostname or ""
|
||||
if _is_private_host(redir_host):
|
||||
if await _is_private_host(redir_host):
|
||||
raise HTTPException(status_code=400, detail="订阅链接重定向到内网地址,已阻止")
|
||||
url = redirect_url
|
||||
resp = await client.get(redirect_url)
|
||||
@@ -957,6 +938,16 @@ class IpAllowlistSaveRequest(BaseModel):
|
||||
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||||
enabled: Optional[bool] = None # None = don't change
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_manual_ips(self):
|
||||
for raw in self.manual_ips:
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
|
||||
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
async def set_ip_allowlist(
|
||||
|
||||
Reference in New Issue
Block a user