mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +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:
@@ -72,7 +72,8 @@ def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
|
||||
else:
|
||||
raise CustomException(msg="不支持的 OAuth 渠道")
|
||||
if not cid or not sec:
|
||||
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)")
|
||||
# 渠道密钥为空属**部署配置缺失**:客户端无论怎么改请求都无法成功,按 500 返回以免监控误判为客户端错误
|
||||
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)", status_code=500)
|
||||
return cid, sec
|
||||
|
||||
|
||||
@@ -136,7 +137,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> Any:
|
||||
except json.JSONDecodeError:
|
||||
text = r.text
|
||||
logger.error(f"OAuth 非 JSON 响应: {text[:500]}")
|
||||
raise CustomException(msg="OAuth 接口返回异常")
|
||||
raise CustomException(msg="OAuth 接口返回异常", status_code=500)
|
||||
|
||||
|
||||
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
|
||||
|
||||
@@ -81,7 +81,9 @@ async def auto_register_login_user(
|
||||
existing = await UserCRUD(auth, db).get(username=username)
|
||||
if existing:
|
||||
return existing
|
||||
raise CustomException(msg=fail_msg)
|
||||
# 原始异常被吞掉(可能是 DB/唯一约束/驱动等未知服务端故障):按 500 返回,
|
||||
# 避免监控把内部故障误判为客户端错误;确实的客户端问题由 UserService 的 CustomException 透传
|
||||
raise CustomException(msg=fail_msg, status_code=500)
|
||||
user = await UserCRUD(auth, db).get(username=username)
|
||||
if not user:
|
||||
raise CustomException(msg=fail_msg)
|
||||
|
||||
@@ -67,7 +67,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> dict:
|
||||
return r.json()
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"微信 API 非 JSON 响应: {r.text[:500]}")
|
||||
raise CustomException(msg="微信接口返回异常")
|
||||
raise CustomException(msg="微信接口返回异常", status_code=500)
|
||||
|
||||
|
||||
async def _get_access_token(redis: Redis) -> str:
|
||||
|
||||
@@ -121,7 +121,7 @@ class DictTypeService:
|
||||
logger.info(f"创建字典类型成功: {new_obj_dict}")
|
||||
except Exception as e:
|
||||
logger.error(f"创建字典类型失败: {e}")
|
||||
raise CustomException(msg="同步字典类型缓存失败") from e
|
||||
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
|
||||
|
||||
return new_obj_dict
|
||||
|
||||
@@ -176,7 +176,7 @@ class DictTypeService:
|
||||
logger.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新字典类型缓存失败: {e}")
|
||||
raise CustomException(msg="同步字典类型缓存失败") from e
|
||||
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
|
||||
|
||||
return new_obj_dict
|
||||
|
||||
@@ -215,7 +215,7 @@ class DictTypeService:
|
||||
logger.info(f"删除字典类型缓存: {dt}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除字典类型缓存失败: {e}")
|
||||
raise CustomException(msg="同步删除字典缓存失败") from e
|
||||
raise CustomException(msg="同步删除字典缓存失败", status_code=500) from e
|
||||
await DictTypeCRUD(self.auth, self.db).delete(ids=ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
@@ -330,7 +330,7 @@ class DictDataService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌️ 字典初始化过程发生错误: {e}")
|
||||
raise CustomException(msg="字典数据初始化失败") from e
|
||||
raise CustomException(msg="字典数据初始化失败", status_code=500) from e
|
||||
|
||||
@staticmethod
|
||||
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
|
||||
@@ -376,7 +376,7 @@ class DictDataService:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取字典缓存失败: {e!s}")
|
||||
raise CustomException(msg="获取字典数据失败") from e
|
||||
raise CustomException(msg="获取字典数据失败", status_code=500) from e
|
||||
|
||||
async def _refresh_dict_cache(self, redis: Redis, dict_type: str) -> None:
|
||||
"""刷新指定字典类型的 Redis 缓存
|
||||
@@ -420,7 +420,7 @@ class DictDataService:
|
||||
logger.info(f"创建字典数据写入缓存成功: {obj}")
|
||||
except Exception as e:
|
||||
logger.error(f"创建字典数据写入缓存失败: {e}")
|
||||
raise CustomException(msg="同步字典数据缓存失败") from e
|
||||
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
|
||||
|
||||
return DictDataOutSchema.model_validate(obj)
|
||||
|
||||
@@ -462,7 +462,7 @@ class DictDataService:
|
||||
await self._refresh_dict_cache(redis, dict_type.dict_type)
|
||||
except Exception as e:
|
||||
logger.error(f"刷新旧字典缓存失败: {e}")
|
||||
raise CustomException(msg="同步旧字典数据缓存失败") from e
|
||||
raise CustomException(msg="同步旧字典数据缓存失败", status_code=500) from e
|
||||
|
||||
obj = await DictDataCRUD(self.auth, self.db).update(id=id, data=data)
|
||||
|
||||
@@ -472,7 +472,7 @@ class DictDataService:
|
||||
logger.info(f"更新字典数据写入缓存成功: {obj}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新字典数据写入缓存失败: {e}")
|
||||
raise CustomException(msg="同步字典数据缓存失败") from e
|
||||
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
|
||||
|
||||
return DictDataOutSchema.model_validate(obj)
|
||||
|
||||
@@ -504,7 +504,7 @@ class DictDataService:
|
||||
logger.info(f"删除字典数据并刷新缓存: dict_type={dt}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除字典数据刷新缓存失败: {e}")
|
||||
raise CustomException(msg="同步删除字典数据缓存失败") from e
|
||||
raise CustomException(msg="同步删除字典数据缓存失败", status_code=500) from e
|
||||
await DictDataCRUD(self.auth, self.db).delete(ids=ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
|
||||
@@ -57,10 +57,10 @@ class ParamsService:
|
||||
)
|
||||
if not result:
|
||||
logger.error(f"同步配置到缓存失败: {out}")
|
||||
raise CustomException(msg="同步配置到缓存失败")
|
||||
raise CustomException(msg="同步配置到缓存失败", status_code=500)
|
||||
except Exception as e:
|
||||
logger.error(f"更新系统配置失败: {e}")
|
||||
raise CustomException(msg="同步配置到缓存失败") from e
|
||||
raise CustomException(msg="同步配置到缓存失败", status_code=500) from e
|
||||
|
||||
return out
|
||||
|
||||
@@ -95,7 +95,7 @@ class ParamsService:
|
||||
await ParamsService._sync_configs_to_redis(redis, config_obj)
|
||||
except Exception as e:
|
||||
logger.error(f"❌️ 初始化系统参数到 Redis 失败: {e}")
|
||||
raise CustomException(msg="初始化系统参数到 Redis 失败") from e
|
||||
raise CustomException(msg="初始化系统参数到 Redis 失败", status_code=500) from e
|
||||
|
||||
@staticmethod
|
||||
async def get_init_cache(redis: Redis) -> list[dict]:
|
||||
|
||||
@@ -379,9 +379,12 @@ class UserService:
|
||||
result += "\n错误信息:\n" + "\n".join(error_msgs)
|
||||
return result
|
||||
|
||||
except CustomException:
|
||||
# 业务异常原样透传(导入文件为空 / 缺少必要列 / 单行数据不合法等属客户端可修正,保留 400)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"批量导入用户失败: {e!s}")
|
||||
raise CustomException(msg=f"导入失败: {e!s}") from e
|
||||
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def _process_import_row(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user