mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-15 05:19:17 +00:00
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:
@@ -33,7 +33,7 @@ from app.utils.common_util import CamelCaseUtil, compute_menu_route_first_segmen
|
||||
|
||||
from .crud import GenTableColumnCRUD, GenTableCRUD
|
||||
from .gen_util import GenUtils
|
||||
from .jinja2_template_util import Jinja2TemplateUtil
|
||||
from .jinja2_template_util import SENTINEL_TEXT, Jinja2TemplateUtil
|
||||
from .schema import (
|
||||
GenSyncColumnChange,
|
||||
GenSyncPreviewSchema,
|
||||
@@ -43,6 +43,7 @@ from .schema import (
|
||||
GenTableQueryParam,
|
||||
GenTableSchema,
|
||||
)
|
||||
from .template_safety import assert_python_structure, assert_text_structure
|
||||
|
||||
|
||||
def handle_service_exception(func: Callable) -> Callable:
|
||||
@@ -61,7 +62,9 @@ def handle_service_exception(func: Callable) -> Callable:
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}")
|
||||
# 服务层公共出口:这里捕获到的都是**非业务**异常(模板渲染/DB/反射等内部故障),
|
||||
# 按 500 返回,避免监控把服务端故障误判为客户端错误;业务异常由上面的分支原样透传(保留自身 4xx)
|
||||
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}", status_code=500)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -370,7 +373,7 @@ class GenTableService:
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"导入失败, {e!s}")
|
||||
raise CustomException(msg=f"导入失败, {e!s}", status_code=500)
|
||||
|
||||
@handle_service_exception
|
||||
async def create_table(self, sql: str) -> bool | None:
|
||||
@@ -454,7 +457,7 @@ class GenTableService:
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"创建表结构失败: {e!s}")
|
||||
raise CustomException(msg=f"创建表结构失败: {e!s}", status_code=500)
|
||||
|
||||
@handle_service_exception
|
||||
async def update_gen_table(self, data: GenTableSchema, table_id: int) -> GenTableOutSchema:
|
||||
@@ -516,7 +519,7 @@ class GenTableService:
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=str(e))
|
||||
raise CustomException(msg=str(e), status_code=500)
|
||||
else:
|
||||
raise CustomException(msg="业务表不存在")
|
||||
|
||||
@@ -541,7 +544,7 @@ class GenTableService:
|
||||
# 再删除表信息
|
||||
await GenTableCRUD(self.auth, self.db).delete_gen_table(ids)
|
||||
except Exception as e:
|
||||
raise CustomException(msg=str(e))
|
||||
raise CustomException(msg=str(e), status_code=500)
|
||||
|
||||
@handle_service_exception
|
||||
async def get_gen_table_by_id(self, table_id: int) -> GenTableOutSchema:
|
||||
@@ -660,12 +663,40 @@ class GenTableService:
|
||||
raise CustomException(msg="包名不能为空")
|
||||
|
||||
# 1. 先写代码文件(风险最高,放最前,失败不产生菜单孤儿数据)
|
||||
async def _write_templates(templates: list[str], ctx: dict[str, Any], table_schema: GenTableOutSchema) -> None:
|
||||
repo_root = BASE_DIR.parent.resolve()
|
||||
|
||||
async def _write_templates(
|
||||
templates: list[str],
|
||||
ctx: dict[str, Any],
|
||||
table_schema: GenTableOutSchema,
|
||||
*,
|
||||
reference_ctx: dict[str, Any],
|
||||
dynamic_values: list[str],
|
||||
) -> None:
|
||||
for template in templates:
|
||||
try:
|
||||
render_content = await env.get_template(template).render_async(**ctx)
|
||||
tmpl = env.get_template(template)
|
||||
render_content = await tmpl.render_async(**ctx)
|
||||
# 同一模板用「哨兵值」再渲染一次,得到该模板的规范结构骨架
|
||||
reference_content = await tmpl.render_async(**reference_ctx)
|
||||
file_name = Jinja2TemplateUtil.get_file_name(template, table_schema)
|
||||
full_path = BASE_DIR.parent.joinpath(file_name)
|
||||
# 落盘前三重校验(详见 gencode/template_safety.py):
|
||||
# ① 目标路径必须仍在仓库根内,防止路径穿越改写仓库外的文件;
|
||||
# ② .py 产物必须语法合法,且**结构与哨兵骨架一致**(仅 ast.parse 发现不了注入);
|
||||
# ③ .vue/.ts/.toml 退化为行结构比对,禁止出现骨架之外的语句/标签结构。
|
||||
if not full_path.resolve().is_relative_to(repo_root):
|
||||
raise CustomException(msg=f"生成路径越出仓库范围,已拒绝写入:{file_name}")
|
||||
if file_name.endswith(".py"):
|
||||
assert_python_structure(render_content, reference_content, filename=file_name)
|
||||
else:
|
||||
assert_text_structure(
|
||||
render_content,
|
||||
reference_content,
|
||||
sentinel=SENTINEL_TEXT,
|
||||
dynamic_values=dynamic_values,
|
||||
filename=file_name,
|
||||
)
|
||||
gen_path = str(full_path)
|
||||
os.makedirs(os.path.dirname(gen_path), exist_ok=True)
|
||||
await anyio.Path(gen_path).write_text(render_content, encoding="utf-8")
|
||||
@@ -681,16 +712,30 @@ class GenTableService:
|
||||
os.makedirs(str(d), exist_ok=True)
|
||||
await anyio.Path(str(init_path)).write_text("# -*- coding: utf-8 -*-", encoding="utf-8")
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}")
|
||||
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}", status_code=500)
|
||||
|
||||
await _write_templates(render_info[0], render_info[2], gen_table_schema)
|
||||
safe_schema: GenTableOutSchema = render_info[4]
|
||||
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe_schema))
|
||||
dynamic_values = Jinja2TemplateUtil.iter_render_dynamic_values(safe_schema)
|
||||
await _write_templates(render_info[0], render_info[2], gen_table_schema, reference_ctx=reference_ctx, dynamic_values=dynamic_values)
|
||||
if gen_table_schema.sub and gen_table_schema.sub_table:
|
||||
gen_table_schema.sub_table.package_name = gen_table_schema.package_name
|
||||
if not (gen_table_schema.sub_table.module_name or "").strip():
|
||||
gen_table_schema.sub_table.module_name = gen_table_schema.module_name
|
||||
sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(gen_table_schema, gen_table_schema.sub_table)
|
||||
sub_templates = Jinja2TemplateUtil.get_sub_table_template_list()
|
||||
await _write_templates(sub_templates, sub_ctx, gen_table_schema.sub_table)
|
||||
safe_sub = Jinja2TemplateUtil.safe_render_schema(gen_table_schema.sub_table)
|
||||
sub_reference_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
|
||||
Jinja2TemplateUtil.sentinel_render_schema(safe_schema),
|
||||
Jinja2TemplateUtil.sentinel_render_schema(safe_sub),
|
||||
)
|
||||
await _write_templates(
|
||||
sub_templates,
|
||||
sub_ctx,
|
||||
gen_table_schema.sub_table,
|
||||
reference_ctx=sub_reference_ctx,
|
||||
dynamic_values=dynamic_values,
|
||||
)
|
||||
|
||||
# 2. 代码成功写入后,再创建菜单(避免失败时产生孤儿菜单数据)
|
||||
menu_crud = MenuCRUD(self.auth, self.db)
|
||||
@@ -970,7 +1015,7 @@ class GenTableService:
|
||||
if sub_cfg:
|
||||
await self.sync_db(sn, _sync_sub=False)
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"同步失败: {e!s}")
|
||||
raise CustomException(msg=f"同步失败: {e!s}", status_code=500)
|
||||
|
||||
async def hydrate_sub_table(self, gen_table: GenTableOutSchema) -> None:
|
||||
"""主子表:优先使用已导入的子表配置,否则回退为只读 DB 结构。
|
||||
@@ -1247,6 +1292,8 @@ class GenTableService:
|
||||
await self.hydrate_sub_table(gen_table)
|
||||
self._assert_master_sub_config_valid(gen_table)
|
||||
context = Jinja2TemplateUtil.prepare_context(gen_table)
|
||||
# 安全副本:与 prepare_context 内部使用同一函数得到,专门供「落盘前结构闸门」派生哨兵骨架与真实值集合
|
||||
safe_table = Jinja2TemplateUtil.safe_render_schema(gen_table)
|
||||
template_list = Jinja2TemplateUtil.get_template_list()
|
||||
output_files = [Jinja2TemplateUtil.get_file_name(template, gen_table) for template in template_list]
|
||||
return [template_list, output_files, context, gen_table]
|
||||
return [template_list, output_files, context, gen_table, safe_table]
|
||||
|
||||
Reference in New Issue
Block a user