fix: 审计7个FINDING修复
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

- 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:
Your Name
2026-05-30 22:34:45 +08:00
parent 1a61fae590
commit 36fde6e6ed
3 changed files with 39 additions and 48 deletions
+36 -45
View File
@@ -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(