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(含恶意载荷、闸门红绿、静态护栏)
This commit is contained in:
zhangtao
2026-10-04 05:12:32 +08:00
parent 007864051a
commit 07fa789d2e
39 changed files with 3031 additions and 265 deletions
@@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from .template_safety import is_safe_identifier, is_valid_column_type, normalize_column_type, reserved_word_reason
class GenDBTableSchema(BaseModel):
"""数据库中的表信息(跨方言统一结构)。
@@ -59,6 +61,71 @@ class GenTableColumnSchema(BaseModel):
dict_type: str | None = Field(default="", description="字典类型")
sort: int = Field(default=0, description="排序")
@field_validator("column_name", mode="before")
@classmethod
def validate_column_name(cls, v: str | None) -> str:
"""列名白名单校验:必须能直接充当 Python / JS 标识符。
列名会被原样写进 ``Mapped[..]`` 属性名、``class`` 字段名与前端 ``prop``,
是注入面之一(见 backend/audit-backend.md B1)。非法名(含引号/空格/换行/数字开头等)
在此直接拒绝,避免生成出语法不合法或可被注入的代码。
参数:
- v (str | None): 原始列名。
返回:
- str: 去空白后的合法列名;未填写时返回空串(沿用历史行为)。
异常:
- ValueError: 列名不是合法标识符时抛出。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_safe_identifier(text):
reason = reserved_word_reason(text)
if reason:
raise ValueError(
f"列名 {text!r} 是 {reason},作为字段名会生成非法代码(如 class: Mapped[...]),"
f"请改名后再导入/保存(可改为 {text}_ 之类)",
)
raise ValueError(f"列名 {text!r} 不是合法的标识符(仅允许字母/数字/下划线,且不能以数字开头),代码生成器无法为它生成合法代码,请先改名")
return text
@field_validator("column_type", mode="before")
@classmethod
def validate_column_type(cls, v: str | None) -> str:
"""列类型形状白名单校验(结构化,非法即拒绝)。
允许的形状:``基名`` + 可选 ``(数字)`` / ``(数字,数字)`` + 可选 ``[]`` + 可选 ``unsigned``。
MySQL 的 ``enum(...)``/``set(...)`` 会先归一化为基名 ``enum``/``set``(其成员列表既无用途也含引号)。
为什么必须校验:列类型会参与「代码位置」派生(SQLAlchemy 类型名 + 长度/精度),
任意文本一旦被拼接进生成代码就等于注入面(见 backend/audit-backend.md B1)。
参数:
- v (str | None): 原始列类型。
返回:
- str: 归一化后的列类型;未填写时返回空串(由 SQLAlchemy 映射回落 String)。
异常:
- ValueError: 形状非法时抛出(报错带上列类型原文)。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_valid_column_type(text):
raise ValueError(
f"列类型 {text!r} 形状非法:仅允许「类型名 + 可选(长度[,精度]) + 可选[] + 可选 unsigned」,例如 varchar(64)、decimal(10,2)、tinyint(1) unsigned;"
"含引号/分号/括号内非数字等内容会被拒绝(代码生成器不接收自由文本类型)",
)
return normalize_column_type(text)
class GenTableColumnOutSchema(GenTableColumnSchema, BaseSchema):
"""业务表字段输出模型