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 资产管理系统
# 写操作(POST/PUT/DELETE)鉴权密钥
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
# Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带;
# 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用)
API_KEY=
# Agent 上报鉴权密钥(可选)
+4 -2
View File
@@ -1,7 +1,8 @@
"""应用配置
从项目根目录的 .env 文件加载配置(若存在)。
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。
API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥;
留空则外部来源一律拒绝(仅内网可用)。
"""
import os
@@ -17,7 +18,8 @@ class Settings:
"""应用配置项"""
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", "")
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
+46 -5
View File
@@ -1,17 +1,26 @@
"""API Key 认证依赖
用于保护写操作(POST/PUT/DELETE)。
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401
- 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行;
- 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)
"""
import hmac
import ipaddress
from typing import Optional
from fastapi import Header, HTTPException, status
from fastapi import Header, HTTPException, Request, status
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:
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
@@ -20,12 +29,44 @@ def _key_matches(provided: Optional[str], expected: str) -> bool:
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(
request: Request,
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None:
"""校验 API Key(可选启用)"""
if not settings.API_KEY:
"""校验 API KeyTailscale 内网/本机放行,外部来源必须携带有效 Key"""
if _is_trusted(_client_ip(request)):
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):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+26 -2
View File
@@ -9,6 +9,7 @@ from typing import Dict, List
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
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:
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用"""
stmt = select(Account).where(Account.name == name, Account.platform == platform)
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
避免出现同名账号在不同"空平台"上各存一条。
"""
stmt = select(Account).where(
Account.name == name,
func.coalesce(Account.platform, "") == platform,
)
if exclude_id is not None:
stmt = stmt.where(Account.id != exclude_id)
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.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
session.add(account)
session.commit()
except IntegrityError:
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
session.refresh(account)
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)
if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
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)
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>
<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="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>
</form>
</div>
+5
View File
@@ -26,6 +26,7 @@ const store = reactive({
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
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: '' } };
}
async function saveAccount() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
if (store.accountSaving) return;
store.error = '';
store.accountSaving = true;
const f = store.accountModal.form;
const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null,
@@ -196,6 +200,7 @@ async function saveAccount() {
// 重命名会同步更新资产端引用,需一并刷新资产
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { store.error = e.message; }
finally { store.accountSaving = false; }
}
async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?';