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
+185 -8
View File
@@ -1,5 +1,7 @@
import asyncio
import json
import re
from collections.abc import Callable
from datetime import datetime
from typing import Any
@@ -94,6 +96,16 @@ class SchedulerUtil:
"""定时任务 SDK — 封装 APScheduler 核心操作与任务执行记录落库。"""
redis_instance: Redis | None = None
# ── 内置处理器白名单(「脚本类」与「内置函数类」任务的分界)────────────────
# 节点任务 func 形如 "builtin:<模块名>[.<函数名>]"(模块限定在 handlers 包内)时,
# 由注册表解析后**直接调用**,不经过 exec —— 因此不受 SCHEDULER_ALLOW_CODE_EXEC 限制。
# 这样该开关只关闭"执行用户提交代码块"的能力,而不是整个调度特性。
BUILTIN_PREFIX = "builtin:"
BUILTIN_HANDLERS_MODULE = "app.modules.task.cronjob.node.handlers"
# 名称白名单:仅允许「模块名」或「模块名.函数名」,禁止点号穿越 / 括号 / 特殊字符
_BUILTIN_NAME_RE = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$")
_builtin_handlers: dict[str, Callable[..., Any]] = {}
# 多 worker 选主状态:_redis 为空视为单机直跑;_leader_token 非空表示本进程持有调度锁
_redis: Redis | None = None
_leader_token: str | None = None
@@ -244,6 +256,16 @@ class SchedulerUtil:
continue
want_ids.add(str(node.id))
# 可观测性(t15):把"已启用但当前不会执行"的节点与存量 job 显式打出来,
# 否则开关调整后只会表现为"任务不动了",运维无从定位
for node in nodes:
if node.status != 0 or not node.trigger:
continue
schedulable, reason = cls.describe_schedulability(node.func)
if not schedulable:
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node.id} name={node.name!r} 原因={reason}")
cls.log_unschedulable_jobs(context="启动自愈")
for job_id in existing:
if job_id.isdigit() and job_id not in want_ids:
try:
@@ -419,7 +441,7 @@ class SchedulerUtil:
"status": status,
"error": _humanize_job_error(detail) if status == JOB_STATUS_FAILED else None,
"result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None),
"next_run_time": str(job.next_run_time) if job and job.next_run_time else None,
"next_run_time": str(getattr(job, "next_run_time", None)) if getattr(job, "next_run_time", None) else None,
"job_state": SchedulerUtil._get_job_state(job) if job else None,
}
cls._record_job_log(record)
@@ -434,7 +456,7 @@ class SchedulerUtil:
pass
return
# 一次性 date 计划执行完成(无下次运行时间)后移除
if job and job.next_run_time is None and isinstance(getattr(job, "trigger", None), DateTrigger):
if job and getattr(job, "next_run_time", None) is None and isinstance(getattr(job, "trigger", None), DateTrigger):
try:
SchedulerUtil.remove_job(job_id=job_id)
logger.info(f"一次性 date 任务 {job_id} 已完成,已从调度器移除")
@@ -508,7 +530,8 @@ class SchedulerUtil:
job = cls.get_job(job_id=str(job_id))
if not job:
return 3
if job.next_run_time is None:
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
if getattr(job, "next_run_time", None) is None:
return 1
if scheduler.state == 0:
return 2
@@ -540,16 +563,170 @@ class SchedulerUtil:
logger.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}")
return temp_job
@classmethod
def describe_schedulability(cls, code_block: str | None) -> tuple[bool, str | None]:
"""判断某个 func(代码块 / ``builtin:`` 引用)当前是否可调度。
单一事实来源:创建/更新守卫、节点列表状态字段、调度器巡检日志都调用它,
避免各处对 ``SCHEDULER_ALLOW_CODE_EXEC`` 的理解不一致。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- tuple[bool, str | None]: (是否可调度, 不可调度原因)。
"""
raw = (code_block or "").strip()
if not raw:
return False, "未配置函数或代码块(节点不会执行)"
if raw.startswith(cls.BUILTIN_PREFIX):
try:
cls.resolve_builtin_handler(raw)
except ValueError as exc:
return False, f"内置处理器不可用:{exc}"
return True, None
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
return False, (
"服务端已禁用代码块执行(SCHEDULER_ALLOW_CODE_EXEC=False);"
"请改用内置函数型任务——把 func 改为 builtin:<模块名>[.<函数名>](不经 exec,不受该开关限制)"
)
return True, None
@classmethod
def job_code_block(cls, job: Job | None) -> str | None:
"""从调度器 job 的 args 中取回 func(约定见 _add_job_with_trigger: args=[job_id, code_block, *args])。
参数:
- job (Job | None): 调度器任务。
返回:
- str | None: func 原文;无法取得时返回 None。
"""
args = list(getattr(job, "args", None) or [])
if len(args) < 2 or args[1] is None:
return None
return str(args[1])
@classmethod
def iter_unschedulable_jobs(cls) -> list[tuple[str, str, str]]:
"""列出调度器中「已注册但当前不可执行」的任务:[(job_id, job_name, reason)]。
用于运维可观测性:开关调整或内置处理器改名后,存量任务不会静默失效。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
result: list[tuple[str, str, str]] = []
for job in cls.get_jobs():
schedulable, reason = cls.describe_schedulability(cls.job_code_block(job))
if not schedulable and reason:
result.append((str(job.id), str(job.name or ""), reason))
return result
@classmethod
def log_unschedulable_jobs(cls, *, context: str) -> list[tuple[str, str, str]]:
"""把「已注册但不可执行」的任务以 WARN 打出(带具体任务标识),返回同一清单。
参数:
- context (str): 调用场景(如 "启动自愈" / "调度器状态查询"),便于日志定位。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
found = cls.iter_unschedulable_jobs()
for job_id, job_name, reason in found:
logger.warning(f"[{context}] 调度器中的任务当前不可执行: id={job_id} name={job_name!r} 原因={reason}")
return found
@classmethod
def register_builtin_handler(cls, name: str, handler: Callable[..., Any]) -> None:
"""注册一个内置处理器(供无 exec 的「内置函数型任务」使用)。
参数:
- name (str): 名称(``func = "builtin:<name>"`` 时引用)。
- handler (Callable): 可调用对象。
返回:
- None
"""
cls._builtin_handlers[name] = handler
@classmethod
def resolve_builtin_handler(cls, code_block: str | None) -> Callable[..., Any] | None:
"""解析 ``builtin:<name>`` 引用;非内置引用返回 None。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- Callable | None: 内置处理器;不是内置引用时返回 None。
异常:
- ValueError: 引用形态非法、模块/函数不存在或不是可调用对象时抛出
(**绝不回退到 exec**,避免"看起来是内置任务、实际被执行成代码块")。
"""
raw = (code_block or "").strip()
if not raw.startswith(cls.BUILTIN_PREFIX):
return None
name = raw[len(cls.BUILTIN_PREFIX) :].strip()
if not cls._BUILTIN_NAME_RE.match(name):
raise ValueError(f"内置处理器名称非法(仅允许 模块名[.函数名],实际 {raw!r})")
cached = cls._builtin_handlers.get(name)
if cached is not None:
return cached
module_name, _, func_name = name.partition(".")
func_name = func_name or module_name
import importlib
module_path = f"{cls.BUILTIN_HANDLERS_MODULE}.{module_name}"
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
raise ValueError(f"内置处理器模块不存在:{module_path}") from exc
handler = getattr(module, func_name, None)
if handler is None or not callable(handler):
raise ValueError(f"内置处理器不可用({module_path}.{func_name} 不存在或不可调用)")
# 安全加固(复核 R1):只接受「在本模块内定义」或「该模块显式声明」的可调用对象。
# 否则 handler 模块里任何一条 `from os import system` 之类的导入,都会变成
# 绕过 SCHEDULER_ALLOW_CODE_EXEC 的无限制执行入口。
declared = getattr(module, "HANDLERS", None)
if isinstance(declared, dict):
if declared.get(func_name) is not handler:
raise ValueError(f"内置处理器未在该模块的 HANDLERS 中声明:{module_path}.{func_name}")
elif getattr(handler, "__module__", None) != module.__name__:
raise ValueError(
f"内置处理器必须在本模块内定义或经 HANDLERS 声明:{module_path}.{func_name} "
f"实际来自 {getattr(handler, '__module__', '未知来源')},已拒绝"
)
cls._builtin_handlers[name] = handler
return handler
@classmethod
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
"""任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor)
安全提示:code_block 来自页面提交,exec 等同给所有能创建任务的人
服务器代码执行权限。生产环境应设置 SCHEDULER_ALLOW_CODE_EXEC=False
关闭该能力,仅保留内置函数型任务。
两类任务:
- **内置函数型**(``code_block = "builtin:<模块名>[.<函数名>]"``):走白名单注册表直接调用,
**不使用 exec**,因此不受 ``SCHEDULER_ALLOW_CODE_EXEC`` 限制;
- **代码块型**(其它内容):``exec`` 等于给所有能创建任务的人服务器代码执行权限,**默认关闭**,
关闭时执行入口直接失败并把原因写入执行日志(而不是静默跳过)。
"""
if code_block and code_block.strip() and not settings.SCHEDULER_ALLOW_CODE_EXEC:
message = f"任务 {job_id} 含用户提交代码块,已因 SCHEDULER_ALLOW_CODE_EXEC=False 拒绝执行"
# 内置函数型任务(builtin:<name>):白名单解析后直接调用,不经过 exec,
# 因此即使 SCHEDULER_ALLOW_CODE_EXEC=False 也能正常调度
raw_code = (code_block or "").strip()
if raw_code.startswith(cls.BUILTIN_PREFIX):
handler = cls.resolve_builtin_handler(raw_code)
logger.info(f"任务 {job_id} 以内置处理器执行(不经 exec): {raw_code}")
return handler(*args, **kwargs) if handler else None
if raw_code and not settings.SCHEDULER_ALLOW_CODE_EXEC:
message = (
f"任务 {job_id} 含用户提交的代码块,已拒绝执行:服务端默认禁止定时任务执行代码块。"
f"如确需该能力,请显式设置 SCHEDULER_ALLOW_CODE_EXEC=true(等同授予服务器代码执行权限,请谨慎评估);"
f"若只需内置能力,可把 func 改为 builtin:<模块名>[.<函数名>]"
)
logger.error(message)
raise RuntimeError(message)
+14 -11
View File
@@ -78,7 +78,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(sql)
return result.scalars().first()
except Exception as e:
raise CustomException(msg=f"获取查询失败: {e!s}") from e
raise CustomException(msg=f"获取查询失败: {e!s}", status_code=500) from e
async def get_or_404(
self,
@@ -89,12 +89,15 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
include_deleted: bool = False,
**kwargs,
) -> ModelType | OutSchemaType:
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。"""
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。
「资源不存在」属客户端可修正的结果:按 404 返回(业务 code 保持默认,前端仍展示 msg)。
"""
if id is not None:
kwargs["id"] = id
obj = await self.get(preload=preload, include_deleted=include_deleted, **kwargs)
if not obj:
raise CustomException(msg=msg)
raise CustomException(msg=msg, status_code=404)
return out_schema.model_validate(obj) if out_schema else obj
async def exists(self, include_deleted: bool = False, **kwargs) -> bool:
@@ -109,7 +112,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(count_sql)
return result.scalar() or 0
except Exception as e:
raise CustomException(msg=f"统计失败: {e!s}") from e
raise CustomException(msg=f"统计失败: {e!s}", status_code=500) from e
async def get_list(
self,
@@ -131,7 +134,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(sql)
return result.scalars().all()
except Exception as e:
raise CustomException(msg=f"列表查询失败: {e!s}") from e
raise CustomException(msg=f"列表查询失败: {e!s}", status_code=500) from e
async def page(
self,
@@ -179,7 +182,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
items=items,
)
except Exception as e:
raise CustomException(msg=f"分页查询失败: {e!s}") from e
raise CustomException(msg=f"分页查询失败: {e!s}", status_code=500) from e
# ── 写入 ──────────────────────────────────────────────────────────
@@ -215,7 +218,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
return obj
except Exception as e:
raise CustomException(msg=f"创建失败: {e!s}") from e
raise CustomException(msg=f"创建失败: {e!s}", status_code=500) from e
async def update(self, id: int, data: UpdateSchemaType | dict[str, Any]) -> ModelType:
"""更新记录。用 exclude_unset / exclude_none 准确表达前端意图。"""
@@ -253,7 +256,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"更新失败: {e!s}") from e
raise CustomException(msg=f"更新失败: {e!s}", status_code=500) from e
async def delete(self, ids: list[int]) -> None:
"""软删除优先,无软删除则物理删除。"""
@@ -269,7 +272,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"删除失败: {e!s}") from e
raise CustomException(msg=f"删除失败: {e!s}", status_code=500) from e
async def clear(self) -> None:
"""清空整表。软删除模式下相当于"回收站清空",只清理已删标记的记录。"""
@@ -283,7 +286,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"清空失败: {e!s}") from e
raise CustomException(msg=f"清空失败: {e!s}", status_code=500) from e
async def set(self, ids: list[int], include_deleted: bool = False, **kwargs) -> None:
"""批量更新。软删除模式下默认跳过已删除的记录。"""
@@ -296,7 +299,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"批量更新失败: {e!s}") from e
raise CustomException(msg=f"批量更新失败: {e!s}", status_code=500) from e
# ── 条件与排序 ────────────────────────────────────────────────────
+64 -5
View File
@@ -38,17 +38,72 @@ def require_superadmin(func):
return wrapper
# starlette 新版本把 HTTP_422_UNPROCESSABLE_ENTITY 标为弃用,这里取新常量并兼容旧版本,
# 避免模块导入期就打 DeprecationWarning
_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422)
# 业务错误码 → HTTP 状态码。只映射语义明确的码;其余业务异常统一按 _DEFAULT_BUSINESS_STATUS 返回。
# 说明:历史上 CustomException 的 status_code 默认 500,导致「验证码过期」「参数校验失败」这类
# 客户端错误也返回 500 —— 前端无法区分「我错了」与「服务器坏了」,监控/告警也被污染。
_CODE_TO_HTTP_STATUS: dict[int, int] = {
RET.BAD_REQUEST.code: status.HTTP_400_BAD_REQUEST,
RET.UNAUTHORIZED.code: status.HTTP_401_UNAUTHORIZED,
RET.FORBIDDEN.code: status.HTTP_403_FORBIDDEN,
RET.NOT_FOUND.code: status.HTTP_404_NOT_FOUND,
RET.CONFLICT.code: status.HTTP_409_CONFLICT,
RET.UNPROCESSABLE_ENTITY.code: _HTTP_422,
RET.TOO_MANY_REQUESTS.code: status.HTTP_429_TOO_MANY_REQUESTS,
RET.SERVICE_UNAVAILABLE.code: status.HTTP_503_SERVICE_UNAVAILABLE,
RET.DATAEXIST.code: status.HTTP_409_CONFLICT,
RET.PARAMERR.code: status.HTTP_400_BAD_REQUEST,
RET.TIMEOUT.code: status.HTTP_504_GATEWAY_TIMEOUT,
RET.RATE_LIMIT_EXCEEDED.code: status.HTTP_429_TOO_MANY_REQUESTS,
# 认证类业务码(对应前端 ResultEnum)
RET.INVALID_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
RET.EXPIRED_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
RET.INVALID_CREDENTIALS.code: status.HTTP_401_UNAUTHORIZED,
RET.TOKEN_EXPIRED.code: status.HTTP_401_UNAUTHORIZED,
RET.NO_PERMISSION.code: status.HTTP_403_FORBIDDEN,
# 显式声明为服务端错误的码(供内部包装器使用,如 CRUD「xx失败」兜底)
RET.SERVERERR.code: status.HTTP_500_INTERNAL_SERVER_ERROR,
}
# 未映射业务码的默认状态:业务异常按「客户端请求错误」处理(而不是 500)
_DEFAULT_BUSINESS_STATUS: int = status.HTTP_400_BAD_REQUEST
def resolve_http_status(code: int | None) -> int:
"""把业务错误码解析为 HTTP 状态码。
参数:
- code (int | None): 业务错误码。
返回:
- int: 对应的 HTTP 状态码;未映射的码返回 _DEFAULT_BUSINESS_STATUS。
"""
if code is None:
return _DEFAULT_BUSINESS_STATUS
return _CODE_TO_HTTP_STATUS.get(int(code), _DEFAULT_BUSINESS_STATUS)
class CustomException(Exception):
"""业务异常。
``status_code`` 未显式传入时按 ``code`` 推导(见 :func:`resolve_http_status`),
默认 400(客户端可修正)而非 500;只有确属服务端故障的场景才应显式传 500,
或使用 ``code=RET.SERVERERR.code``。
"""
def __init__(
self,
msg: str = RET.EXCEPTION.msg,
code: int = RET.EXCEPTION.code,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code: int | None = None,
data: Any | None = None,
success: bool = False,
) -> None:
super().__init__(msg)
self.status_code = status_code
self.status_code = status_code if status_code is not None else resolve_http_status(code)
self.code = code
self.msg = msg
self.data = data
@@ -61,11 +116,15 @@ class CustomException(Exception):
def handle_exception(app: FastAPI) -> None:
@app.exception_handler(CustomException)
async def custom_exception_handler(request: Request, exc: CustomException) -> JSONResponse:
logger.error(
"[自定义异常] {} {} | code={} | msg={} | data={}",
# 4xx 是「客户端可修正」的业务结果,5xx 才是服务端故障:分级记录,
# 避免每个校验失败都打 ERROR 把真正的故障信号淹没。
log = logger.error if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR else logger.warning
log(
"[自定义异常] {} {} | code={} | status={} | msg={} | data={}",
request.method,
request.url.path,
exc.code,
exc.status_code,
exc.msg,
exc.data,
)
@@ -96,7 +155,7 @@ def handle_exception(app: FastAPI) -> None:
request.url.path,
errors,
)
return ErrorResponse(msg=str(msg), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, data=errors)
return ErrorResponse(msg=str(msg), status_code=_HTTP_422, data=errors)
@app.exception_handler(ResponseValidationError)
async def response_validation_handler(request: Request, exc: ResponseValidationError) -> JSONResponse: