fix: settings 安全加固 + UX 迭代 — 10项修复

安全修复:
- S-01 SSRF: parse-subscription 加私有IP封禁+重定向限制+响应大小限制
- S-02 值校验: PUT /{key} 加 key 白名单 + INT 范围校验(1-1000/1-100/10-600)
- S-05 重登录: 改密码成功后跳转login.html+绿色提示
- S-07 审计: add_manual_ips/remove_allowlist_ip 补全审计日志
- S-09 TOTP: 已启用TOTP时改密码需输入验证码

UX改进:
- UX-01: 密码框focus ring + 设置项input type映射(number/url/text)
- UX-02: 改密码按钮loading状态(disabled+提交中...)
- UX-03: 3处空catch块→console.error+toast
- UX-04: 保存失败时reloadSettings恢复原值
- UX-05: IP格式校验(前端正则+后端Pydantic model_validator)
This commit is contained in:
Your Name
2026-05-30 21:58:13 +08:00
parent 362b28199f
commit a50f17941d
5 changed files with 195 additions and 26 deletions
+9
View File
@@ -99,6 +99,7 @@ class TotpVerifyRequest(BaseModel):
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
new_password: str = Field(..., min_length=6, max_length=255)
totp_code: Optional[str] = None # S-09: required if TOTP enabled
class WebsshTokenRequest(BaseModel):
@@ -285,6 +286,14 @@ async def change_password(
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
raise HTTPException(status_code=400, detail="当前密码错误")
# S-09: If TOTP is enabled, require TOTP code for password change
if current_admin.totp_enabled:
if not payload.totp_code:
raise HTTPException(status_code=400, detail="已启用 TOTP 的账户修改密码需要验证码")
from server.application.services.auth_service import AuthService
if not AuthService._verify_totp(payload.totp_code, current_admin.totp_secret):
raise HTTPException(status_code=400, detail="TOTP 验证码错误")
# Hash new password
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
+135 -8
View File
@@ -7,9 +7,13 @@ Sensitive values are masked in GET responses.
"""
from typing import Optional
import ipaddress
import re
import socket
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
@@ -29,6 +33,52 @@ IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "telegram_bot_token"}
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
MUTABLE_KEYS: set[str] = {
"system_name", "system_title", "db_pool_size", "db_max_overflow",
"redis_url", "heartbeat_timeout", "api_base_url",
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
"telegram_bot_token", "telegram_chat_id",
"login_allowlist_enabled", "login_subscription_url",
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
"notify_recovery", "notify_time_drift",
"notify_system_redis", "notify_system_mysql", "notify_restart",
}
# S-02: Per-key validation rules: (type, min, max) for int settings
SETTING_VALIDATORS: dict[str, tuple] = {
"db_pool_size": (int, 1, 1000),
"db_max_overflow": (int, 1, 1000),
"heartbeat_timeout": (int, 10, 600),
"cpu_alert_threshold": (int, 1, 100),
"mem_alert_threshold": (int, 1, 100),
"disk_alert_threshold": (int, 1, 100),
}
# S-01: SSRF protection — check if hostname resolves to private/link-local IP
def _is_private_host(hostname: str) -> bool:
"""Check if hostname resolves to any private/loopback/link-local IP."""
if not hostname:
return True
# Direct IP check
try:
ip = ipaddress.ip_address(hostname)
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)
try:
infos = socket.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:
return True
except (socket.gaierror, ValueError, OSError):
return True # DNS failure → block
return False
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -135,6 +185,19 @@ async def set_setting(
"""
if key in IMMUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
if key not in MUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
# S-02: Validate value type and range for int settings
val_str = str(payload.value)
if key in SETTING_VALIDATORS:
expected_type, min_val, max_val = SETTING_VALIDATORS[key]
try:
int_val = int(val_str)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"'{key}' 必须是整数") from None
if int_val < min_val or int_val > max_val:
raise HTTPException(status_code=400, detail=f"'{key}' 范围: {min_val}-{max_val}")
repo = SettingRepositoryImpl(db)
setting = await repo.set(key, str(payload.value))
@@ -790,8 +853,22 @@ async def telegram_get_chats(
# ── IP Allowlist ──
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
_DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$")
class IpAllowlistRequest(BaseModel):
ips: list[str] = Field(..., description="IP or hostname list to set as allowlist")
ips: list[str] = Field(..., min_length=1, max_length=100, description="IP or hostname list")
@model_validator(mode="after")
def validate_ips(self):
for raw in self.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
class SubscriptionParseRequest(BaseModel):
@@ -810,19 +887,47 @@ async def parse_subscription(
Supports SS, VMess, VLESS, Trojan, Hysteria2 formats.
Returns the parsed host list for the user to review before saving.
"""
from urllib.parse import urlparse
import httpx
from server.infrastructure.subscription_parser import parse_subscription_content
url = payload.url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
# S-01: SSRF protection — block private/link-local/loopback IPs
hostname = parsed.hostname or ""
if _is_private_host(hostname):
raise HTTPException(status_code=400, detail="不允许访问内网地址")
MAX_BODY = 1_000_000 # 1 MB
MAX_REDIRECTS = 3
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(url, follow_redirects=True)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
raw = resp.text
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client:
resp = await client.get(url)
# Manual redirect following with SSRF check on each hop
redirects = 0
while resp.is_redirect and redirects < MAX_REDIRECTS:
redirects += 1
location = resp.headers.get("location", "")
if not location:
break
# Resolve relative redirects
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):
raise HTTPException(status_code=400, detail="订阅链接重定向到内网地址,已阻止")
url = redirect_url
resp = await client.get(redirect_url)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
# S-01: Limit response body size
if len(resp.content) > MAX_BODY:
raise HTTPException(status_code=400, detail="订阅内容超过 1MB 限制")
raw = resp.text
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s") from None
except HTTPException:
@@ -942,6 +1047,7 @@ async def toggle_ip_allowlist(
@router.post("/ip-allowlist/manual", response_model=dict)
async def add_manual_ips(
payload: IpAllowlistRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -956,6 +1062,16 @@ async def add_manual_ips(
await repo.set("login_manual_ips", val)
_settings.LOGIN_MANUAL_IPS = val
# S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="add_manual_ips",
target_type="setting",
detail=f"添加 {len(new_ips)} 条手动 IP: {', '.join(new_ips[:5])}",
ip_address=request.client.host if request.client else "",
))
from server.background.ip_allowlist_refresh import _do_refresh
import asyncio
asyncio.create_task(_do_refresh())
@@ -965,6 +1081,7 @@ async def add_manual_ips(
@router.delete("/ip-allowlist/ip", response_model=dict)
async def remove_allowlist_ip(
ip: str,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -978,6 +1095,16 @@ async def remove_allowlist_ip(
await repo.set("login_manual_ips", val)
_settings.LOGIN_MANUAL_IPS = val
# S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="remove_allowlist_ip",
target_type="setting",
detail=f"移除白名单 IP: {ip}",
ip_address=request.client.host if request.client else "",
))
from server.background.ip_allowlist_refresh import _do_refresh
import asyncio
asyncio.create_task(_do_refresh())
+2 -1
View File
@@ -477,7 +477,8 @@ class AuthService:
"""Verify password against stored bcrypt hash"""
return bcrypt.checkpw(password.encode(), password_hash.encode())
def _verify_totp(self, code: str, secret: str) -> bool:
@staticmethod
def _verify_totp(code: str, secret: str) -> bool:
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
import base64
key = base64.b32decode(secret, casefold=True)
+12
View File
@@ -317,6 +317,18 @@
}
}).catch(() => {});
})();
// S-05: Show message from URL parameter (e.g., after password change redirect)
(function() {
const msg = new URLSearchParams(window.location.search).get('msg');
if (msg === 'password_changed') {
const el = document.getElementById('lockoutMsg');
el.textContent = '✅ 密码已修改,请重新登录。';
el.className = 'mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm text-center';
el.classList.remove('hidden');
window.history.replaceState({}, '', window.location.pathname);
}
})();
</script>
<style>
+37 -17
View File
@@ -102,12 +102,13 @@
<!-- Change Password -->
<div>
<span class="text-white text-sm font-medium">修改密码</span>
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
<div class="space-y-2 max-w-sm">
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="changePassword()" id="changePwBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
</div>
</div>
</div>
@@ -279,6 +280,9 @@
<script src="/app/vendor/qrious.min.js"></script>
<script>
initLayout('settings');
// UX-01: Input type mapping for settings fields
const INPUT_TYPES={db_pool_size:'number',db_max_overflow:'number',heartbeat_timeout:'number',
cpu_alert_threshold:'number',mem_alert_threshold:'number',disk_alert_threshold:'number',api_base_url:'url'};
const SECTIONS={
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
@@ -310,7 +314,7 @@
}
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}).join('');
@@ -325,7 +329,7 @@
// Notification toggles
renderNotifyToggles(_allSettings);
}catch(e){}
}catch(e){console.error(e);toast('error','加载设置失败')}
}
function renderApiKeySection(){
@@ -476,7 +480,8 @@
async function saveSetting(key){
const v=document.getElementById('set_'+key).value;
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
if(r&&r.ok)toast('success','设置已保存');
else{toast('error','保存失败');loadSettings();}
}
// ── TOTP Management ──
@@ -488,7 +493,7 @@
const admin=await r.json();
_currentAdminId=admin.id;
updateTotpUI(admin.totp_enabled);
}catch(e){}
}catch(e){console.error(e);toast('error','加载TOTP状态失败')}
}
function updateTotpUI(enabled){
@@ -616,28 +621,38 @@
const current=document.getElementById('currentPassword').value;
const newPw=document.getElementById('newPassword').value;
const confirm=document.getElementById('confirmPassword').value;
const totpCode=document.getElementById('totpCodeForPassword')?.value||'';
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
if(newPw.length<6){toast('warning','新密码至少6位');return}
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
// UX-02: Loading state
const btn=document.getElementById('changePwBtn');
btn.disabled=true;btn.textContent='提交中...';
try{
const body={current_password:current,new_password:newPw};
if(totpCode)body.totp_code=totpCode;
const r=await apiFetch(API+'/auth/password',{
method:'PUT',headers:apiHeadersJSON(),
body:JSON.stringify({current_password:current,new_password:newPw})
method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)
});
if(!r){toast('error','请求失败');return}
if(r.ok){
const data=await r.json();
toast('success','密码已修改');
document.getElementById('currentPassword').value='';
document.getElementById('newPassword').value='';
document.getElementById('confirmPassword').value='';
toast('success','密码已修改,正在跳转...');
// S-05: Redirect to login after password change
setTimeout(()=>{
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires');
localStorage.removeItem('admin');
localStorage.removeItem('last_activity');
window.location.href='/app/login.html?msg=password_changed';
},800);
}else{
const err=await r.json().catch(()=>({}));
toast('error',err.detail||'修改失败');
}
}catch(e){toast('error','修改请求失败')}
finally{btn.disabled=false;btn.textContent='修改密码'}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
@@ -813,7 +828,7 @@
document.getElementById('manualIpsList').innerHTML=_manualIps.length
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-slate-600">(空)</span>';
}catch(e){}
}catch(e){console.error(e);toast('error','加载白名单失败')}
}
async function toggleAllowlist(){
@@ -888,6 +903,11 @@
const raw=document.getElementById('manualIps').value;
const newIps=raw.split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
if(!newIps.length){toast('warning','请输入 IP');return}
// UX-05: Frontend IP/CIDR/domain format validation
const ipRe=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?$/;
const domainRe=/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/;
const invalid=newIps.filter(ip=>!(ipRe.test(ip)||domainRe.test(ip)));
if(invalid.length){toast('error','格式无效: '+invalid.join(', '));return}
await addManualIpsToList(newIps);
document.getElementById('manualIps').value='';
}