fix(security): Tailscale内网(100.64/10)免API_KEY+外部强制校验;修复账号重复提交400(提交锁+唯一约束兜底)

This commit is contained in:
gouki
2026-08-15 22:19:51 +00:00
parent b4705331c0
commit 8fea6c2288
6 changed files with 88 additions and 16 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
APP_NAME=VPS 资产管理系统 APP_NAME=VPS 资产管理系统
# 写操作(POST/PUT/DELETE)鉴权密钥 # 写操作(POST/PUT/DELETE)鉴权密钥
# 留空 = 不校验(适用于纯 Tailscale 内网环境) # Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带;
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥> # 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用)
API_KEY= API_KEY=
# Agent 上报鉴权密钥(可选) # Agent 上报鉴权密钥(可选)
+4 -2
View File
@@ -1,7 +1,8 @@
"""应用配置 """应用配置
从项目根目录的 .env 文件加载配置(若存在)。 从项目根目录的 .env 文件加载配置(若存在)。
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。 API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥;
留空则外部来源一律拒绝(仅内网可用)。
""" """
import os import os
@@ -17,7 +18,8 @@ class Settings:
"""应用配置项""" """应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统") APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验 # 写操作(POST/PUT/DELETE)鉴权密钥;Tailscale 内网/本机来源忽略校验
# 外部来源必须携带 X-API-Key(留空则外部来源直接拒绝)
API_KEY: str = os.getenv("API_KEY", "") API_KEY: str = os.getenv("API_KEY", "")
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key # Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
AGENT_KEY: str = os.getenv("AGENT_KEY", "") AGENT_KEY: str = os.getenv("AGENT_KEY", "")
+46 -5
View File
@@ -1,17 +1,26 @@
"""API Key 认证依赖 """API Key 认证依赖
用于保护写操作(POST/PUT/DELETE)。 用于保护写操作(POST/PUT/DELETE)。
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。 - 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行;
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401 - 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)
""" """
import hmac import hmac
import ipaddress
from typing import Optional from typing import Optional
from fastapi import Header, HTTPException, status from fastapi import Header, HTTPException, Request, status
from app.core.config import settings from app.core.config import settings
# 可信来源网段:Tailscale 使用 CGNAT 100.64.0.0/10 分配内网 IP(如 100.89.x.x);
# 回环地址覆盖 tailscale serve 代理转发与本地开发场景。
TRUSTED_NETWORKS = [
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("::1/128"),
]
def _key_matches(provided: Optional[str], expected: str) -> bool: def _key_matches(provided: Optional[str], expected: str) -> bool:
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息""" """常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
@@ -20,12 +29,44 @@ def _key_matches(provided: Optional[str], expected: str) -> bool:
return hmac.compare_digest(provided.encode(), expected.encode()) return hmac.compare_digest(provided.encode(), expected.encode())
def _client_ip(request: Request) -> str:
"""获取客户端真实 IP
tailscale serve 转发到本机时 request.client 为回环地址,真实来源在
X-Forwarded-For 头中;仅信任来自回环的 XFF,防止外部直连时伪造
X-Forwarded-For 绕过鉴权。
"""
host = request.client.host if request.client else ""
if host in ("127.0.0.1", "::1"):
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return host
def _is_trusted(ip: str) -> bool:
"""判断来源 IP 是否属于可信内网(Tailscale 网段 / 本机回环)"""
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in TRUSTED_NETWORKS)
async def require_api_key( async def require_api_key(
request: Request,
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"), x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None: ) -> None:
"""校验 API Key(可选启用)""" """校验 API KeyTailscale 内网/本机放行,外部来源必须携带有效 Key"""
if not settings.API_KEY: if _is_trusted(_client_ip(request)):
return return
if not settings.API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="外部访问需要 API Key,请先在 .env 配置 API_KEY 并设置到前端",
)
if not _key_matches(x_api_key, settings.API_KEY): if not _key_matches(x_api_key, settings.API_KEY):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+30 -6
View File
@@ -9,6 +9,7 @@ from typing import Dict, List
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core import crypto from app.core import crypto
@@ -53,8 +54,15 @@ def _norm_platform(platform: str | None) -> str:
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None: def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用""" """校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
stmt = select(Account).where(Account.name == name, Account.platform == platform)
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
避免出现同名账号在不同"空平台"上各存一条。
"""
stmt = select(Account).where(
Account.name == name,
func.coalesce(Account.platform, "") == platform,
)
if exclude_id is not None: if exclude_id is not None:
stmt = stmt.where(Account.id != exclude_id) stmt = stmt.where(Account.id != exclude_id)
if session.exec(stmt).first(): if session.exec(stmt).first():
@@ -73,8 +81,16 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user) account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
account.login_password_encrypted = crypto.encrypt(data.login_password) account.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config) account.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(account) try:
session.commit() session.add(account)
session.commit()
except IntegrityError:
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
session.refresh(account) session.refresh(account)
return _to_read(account, _asset_counts(session)) return _to_read(account, _asset_counts(session))
@@ -101,8 +117,16 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
account.login_password_encrypted = crypto.encrypt(data.login_password) account.login_password_encrypted = crypto.encrypt(data.login_password)
if data.api_config is not None: if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config) account.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(account) try:
session.commit() session.add(account)
session.commit()
except IntegrityError:
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{account.name}",
)
session.refresh(account) session.refresh(account)
return _to_read(account, _asset_counts(session)) return _to_read(account, _asset_counts(session))
+1 -1
View File
@@ -231,7 +231,7 @@ const AccountModal = {
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p> <p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button> <button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button> <button type="submit" :disabled="store.accountSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.accountSaving ? '保存中…' : '保存' }}</button>
</div> </div>
</form> </form>
</div> </div>
+5
View File
@@ -26,6 +26,7 @@ const store = reactive({
assetModal: { show: false, editing: null, form: null }, assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null }, providerModal: { show: false, editing: null, form: null },
accountModal: { show: false, editing: null, form: null }, accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部 accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
}); });
@@ -181,7 +182,10 @@ function openAccountEdit(a) {
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } }; store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
} }
async function saveAccount() { async function saveAccount() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
if (store.accountSaving) return;
store.error = ''; store.error = '';
store.accountSaving = true;
const f = store.accountModal.form; const f = store.accountModal.form;
const payload = { const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null, name: f.name, platform: f.platform || null, remark: f.remark || null,
@@ -196,6 +200,7 @@ async function saveAccount() {
// 重命名会同步更新资产端引用,需一并刷新资产 // 重命名会同步更新资产端引用,需一并刷新资产
await Promise.all([loadAccounts(), loadAssets()]); await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { store.error = e.message; } } catch (e) { store.error = e.message; }
finally { store.accountSaving = false; }
} }
async function deleteAccount(a) { async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?'; let msg = '确认删除账号「' + a.name + '」?';