Files
FastapiAdmin/backend/app/utils/crypto_util.py
T
zhangtao 07fa789d2e fix(backend): 安全默认值、异常语义、代码生成注入面与定时任务内置处理器
本批为 t10 → t13 → t15 三轮实现 + 一轮复核驳回整改的最终形态(pytest 83 passed / ruff clean,
已部署并线上验证)。

异常语义
- CustomException 默认状态码 500 → 400(约 433 处裸调用不再被计为服务端故障),
  且「包装底层异常」的 130 处显式恢复 500;get_or_404 → 404;响应体结构不变

代码生成注入面(复核 blocker F1,二次修复)
- column_type 不再做字符串拼接:改为常量白名单派生(类型名 ∈ DB_TO_SQLALCHEMY ∪ {Boolean},
  长度仅取数字/正则捕获),彻底移除 split("(")[1]
- 列类型加形状白名单;字符串/注释/文档字符串用 \uXXXX,标识符过 safe_ident(含 Python/JS 关键字),
  .vue 文本与静态属性改 HTML 实体
- 落盘闸门由 ast.parse 升级为 assert_python_structure(语法 + 顶层语句白名单 + 与哨兵骨架逐节点比对)
  与 assert_text_structure(.vue/.ts/.toml 行结构);U+2028/2029 与 C1 控制符纳入转义

安全默认值
- 生产 CORS 不再回落 ["*"](未配置即拒绝跨域,可选 CORS_STRICT_STARTUP 直接启动失败)
- OAUTH_ALLOWED_HOSTS 不再默认 "*";新增 ALLOW_LOCALHOST_HOSTS 控制 Host 校验范围
- ALLOWED_HOSTS 收窄为按配置派生;健康检查暴露 code_exec_enabled / cors_origins_configured

定时任务
- SCHEDULER_ALLOW_CODE_EXEC 默认 False;新增 builtin:<模块名>[.<函数名>] 内置处理器路线(不经 exec)
- 判定收敛到 describe_schedulability;列表返回 schedulable / unschedulable_reason;
  启动 reconcile 与状态查询对「已启用但不可调度」打 WARN(去重、恢复后清除)
- 加固:内置处理器必须在本模块内定义或经 HANDLERS 声明,拒绝模块内导入的外部对象(复核 R1)
- 顺带修复:_WARNED_UNSCHEDULABLE 的 set.pop TypeError、job.next_run_time 缺失导致的列表 500

测试:新增 tests/test_generator_template_safety.py、test_security_defaults.py、
test_scheduler_schedulability.py(含恶意载荷、闸门红绿、静态护栏)
2026-10-04 05:12:32 +08:00

97 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""静态数据加密工具(Fernet 对称加密)。
用于统一「落库/落缓存的敏感字段」加密方式(存储源口令、AI 密钥等),
密钥与 JWT 签名密钥分离:
- 主密钥优先取 ``DATA_ENCRYPTION_KEY``,未配置时由 ``SECRET_KEY`` 经
HKDF-SHA256(带域分隔 info)派生,避免与令牌签名密钥直接同源;
- 解密按 [主密钥, ``DATA_ENCRYPTION_OLD_KEYS`` 中的旧密钥..., 历史 SHA256(SECRET_KEY)]
顺序尝试,因此支持密钥轮换,也不会打断升级前已加密的存量数据。
"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.config.setting import settings
from app.core.exceptions import CustomException
# HKDF 域分隔:与 SECRET_KEY 用于其它目的时派生出不同密钥
_INFO = b"fastapiadmin:data-encryption:v1"
# Fernet token 首字节为版本 0x80,url-safe base64 编码后固定以 "gAAAAA" 开头
_TOKEN_PREFIX = "gAAAAA"
class CryptoUtil:
"""敏感字段加解密(进程内缓存密钥环,配置变更时自动重建)。"""
_cache_signature: tuple | None = None
_fernets: list[Fernet] = []
@classmethod
def _derive_key(cls, secret: str) -> bytes:
return base64.urlsafe_b64encode(
HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO).derive(secret.encode("utf-8"))
)
@classmethod
def _legacy_key(cls, secret: str) -> bytes:
"""升级前的派生方式:直接对 SECRET_KEY 取 SHA-256,仅用于解密历史密文。"""
return base64.urlsafe_b64encode(hashlib.sha256(secret.encode("utf-8")).digest())
@classmethod
def _key_ring(cls) -> list[Fernet]:
signature = (settings.DATA_ENCRYPTION_KEY, settings.DATA_ENCRYPTION_OLD_KEYS, settings.SECRET_KEY)
if cls._cache_signature != signature:
primaries = [settings.DATA_ENCRYPTION_KEY or settings.SECRET_KEY]
primaries += [key.strip() for key in (settings.DATA_ENCRYPTION_OLD_KEYS or "").split(",") if key.strip()]
fernets = [Fernet(cls._derive_key(secret)) for secret in primaries]
fernets.append(Fernet(cls._legacy_key(settings.SECRET_KEY)))
cls._fernets = fernets
cls._cache_signature = signature
return cls._fernets
@classmethod
def encrypt(cls, plain: str | None) -> str:
"""明文 → 密文;空值原样返回空串(始终使用主密钥加密)。"""
if not plain:
return ""
return cls._key_ring()[0].encrypt(plain.encode("utf-8")).decode("utf-8")
@classmethod
def decrypt(cls, cipher: str | None) -> str:
"""密文 → 明文;所有密钥均解不开视为密钥已变更/数据损坏,抛出可定位的异常。"""
if not cipher:
return ""
for fernet in cls._key_ring():
try:
return fernet.decrypt(cipher.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
continue
raise CustomException(msg="敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏", status_code=500)
@classmethod
def decrypt_or_keep(cls, value: str | None) -> str:
"""兼容历史明文的解密:值不是 Fernet 密文格式时原样返回。
用于加密能力上线前就已存在的明文(如 Redis 中的 AI 密钥缓存,
带 TTL,会随用户重新保存自然收敛为密文)。
"""
if not value or not cls.is_token(value):
return value or ""
return cls.decrypt(value)
@staticmethod
def is_token(value: str) -> bool:
"""判断字符串是否具备 Fernet 密文的外形(前缀 + 可 base64 解码 + 最小长度)。"""
if not value.startswith(_TOKEN_PREFIX) or len(value) < 60:
return False
try:
base64.urlsafe_b64decode(value.encode("utf-8"))
except ValueError:
return False
return True