Files
FastapiAdmin/backend/audit-backend.md
T
zhangtao 28bf232761 docs(audit): 全栈审计报告(7 份分报告 + 总报告)
由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行:
- backend/audit-backend.md      后端架构与代码质量(22 条)
- backend/audit-security.md     认证/授权/敏感信息(23 条)
- backend/audit-testing.md      测试与可验证性(含真实命令输出)
- backend/audit-requirements.md 需求覆盖对照(多租户规格错位)
- frontend/audit-frontend.md    前端与移动端工程(32 条)
- frontend/audit-uxui.md        交互与视觉一致性
- docker/audit-deploy.md        容器化与部署链路
- AUDIT-REPORT.md               总报告:止血项、修复路线、待决策事项、对既有结论的修正

各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
2026-10-04 04:26:11 +08:00

44 KiB
Raw Blame History

FastapiAdmin backend 只读审计报告

  • 审计对象:backend/(v3.2.0,FastAPI 0.138 + SQLAlchemy 2.0 async + Redis + MySQL/PostgreSQL/SQLite + Alembic)
  • 审计日期:本轮审计内(工作区 /Users/tao/workspace/FastapiAdmin)
  • 审计方式:只读。除本报告外未修改/删除任何仓库文件,未启动数据库/Redis,未安装依赖,未提交 git。
  • 规模:app/ 下 216 个 .py,约 28,500 行(modules/ 16 个业务模块约 20,000 行,core+config+utils+common 约 7,500 行)。

0. 可复现命令与结果(务必与「未验证项」区分)

# 命令 退出码 关键输出
1 cd backend && uv run pytest -q 2(工具失败) error: Failed to initialize cache at /Users/tao/.cache/uv ... Operation not permitted (os error 1)。uv 缓存目录在工作区之外,被本会话的文件沙箱拒绝;未申请提权(本会话提权被自动拒绝)。
1b cd backend && UV_CACHE_DIR=/tmp/uvcache-test uv run --no-sync pytest -q 0 2 passed in 2.44s
1c cd backend && .venv/bin/python -m pytest -q(等价路径) 0 2 passed
2 cd backend && uv run ruff check 2(同上,缓存失败) 同上
2b cd backend && UV_CACHE_DIR=/tmp/uvcache-test uv run --no-sync ruff check 0 All checks passed!

工具版本:uv 0.12.10、Python 3.12.12、pytest 9.0.2、ruff 0.14.13(均为仓库自带 .venv 中已安装版本)。

结论:静态检查(ruff)干净;测试套件只有 2 个测试用例(tests/test_main.py::test_check_health、tests/test_migrations.py::test_bootstrap_matches_metadata)对约 2.85 万行代码,功能覆盖率实质为零(见 M8)。

未验证项(明确标注,未编造)

  • 本机 3306 / 5432 / 6379 端口均不可达(nc -z 探测),因此所有需要真实 MySQL/PostgreSQL/Redis 的行为均未运行验证:登录限流、Redis 会话续期、APScheduler 分布式锁与 jobstore、Alembic 对真实库的迁移、代码生成器对物理库的 Inspector 反射,均以静态阅读 + 代码路径推演为依据。
  • uv run(不带 --no-sync)在本沙箱不可用,仅能用 UV_CACHE_DIR 指向可写目录 + --no-sync 复现;这一点本身属于「环境限制」,不是仓库缺陷。
  • 测试使用的 sqlite + mock Redis 路径能通过,不代表 MySQL/Redis 生产路径可用。

1. 安全问题(blocker / high)

B1 [blocker] 代码生成器把未转义的用户输入渲染进「会被 import 的 Python 文件」→ 认证用户 RCE / 任意代码注入

位置

  • 模板渲染无转义:backend/app/modules/generator/gencode/jinja2_template_util.py:34(autoescape=False)
  • 渲染值直接嵌入 Python 字符串与标识符:
    • backend/templates/python/model.py.jinja2:16("""{{ function_name }}表""" docstring)、:19、:32、:34、:47、:54、:57、:59(comment='{{ column.column_comment }}')、:61('{{ table_name }}')
    • backend/templates/python/schema.py.jinja2:17,22,24,26,28,30,32,34,36,44,49,51,53,55,57,65,71,76,77,79(同理,且 {{ column.column_name }} 直接当字段名/标识符用)
    • backend/templates/python/controller.py.jinja2:19,20,24,26,...(summary="获取{{ function_name }}详情"、tags=["{{ function_name }}模块"])
  • 落盘位置就是应用自身的插件导入目录:backend/app/modules/generator/gencode/service.py:663-693(_write_templates → BASE_DIR.parent.joinpath("backend/app/plugin/{package_name}/{module_name}/controller.py"),另含 model.py/schema.py/service.py/crud.py/__init__.py)
  • 启动时无条件 import:backend/app/core/discover.py:52(glob("module_*/**/controller.py"))+ :74(importlib.import_module(module_path)),且导入失败会中止启动(:96-99)
  • 未受校验的输入来源:
    • backend/app/modules/generator/gencode/schema.py:27-60:GenTableColumnSchema.column_name / column_comment / function_name 没有任何 pattern/长度/字符集校验(对比 package_name/module_name/business_name 都做了 slug 规范化)
    • backend/app/modules/generator/gencode/service.py:487-502:update_gen_table 把请求体里「库里没有的列」直接 GenTableColumnSchema(table_id=..., **gen_table_column.model_dump(...)) 落库
    • backend/app/modules/generator/gencode/gen_util.py:36-48:function_name 默认取自物理表的表注释,而表注释可被「建表 SQL」功能(service.py:376-457)写入

现象:generate_code(POST /generator/gencode/output/{table_name})会把上述字段原样写进 .py 源码。一个 function_name 形如 x\n"""\nimport os\nos.system("id")\n"""\n 或 column_comment 形如 a'); import os; os.system('id'); # 的值,会让生成的 model.py / schema.py 语法合法且在模块顶层执行任意语句;文件被写入 app/plugin/**,应用下次启动(或 dev reload)即被 discover.py 导入执行。

为什么有害:这是一个听起来只需要「代码生成」权限(module_generator:gencode:update / :code)的能力,实际等价于服务器任意代码执行。而且它是「先写文件、后建菜单」的顺序(service.py:662-698 注释明确:文件先写),菜单创建失败也不会回滚已写入的代码。开发环境 DEBUG=True → reload=True(main.py:42),写入即触发重载执行,攻击窗口从「下次重启」缩短到「保存即生效」。

建议修法(任一都不够,建议全做)

  1. 生成模板改为数据驱动:comment='...' 用 repr()/json.dumps() 产出,标识符类字段先做 ^[a-z_][a-z0-9_]*$ 断言;function_name 这类自由文本只允许出现在渲染时转义的位置(或干脆不写入源码,改成常量表)。
  2. 在 GenTableColumnSchema / GenTableSchema 上补齐校验:column_name 必须匹配 ^[A-Za-z_][A-Za-z0-9_]*$,column_comment / function_name 禁止换行与引号(或长度上限 + 字符集白名单)。
  3. 落盘后用 ast.parse() 校验生成的 .py,语法不合法直接拒绝并拒绝写盘(可防未来新增模板的注入面)。
  4. 生成产物写入独立输出目录(如 backend/generated/,不进 app/plugin),改为人工审核后移动;至少不要落在 discover.py 会自动 import 的路径下。
  5. 该能力单独拆分高权限(如仅超管),并在文档/权限名里写明「等价远程代码执行」。

B2 [blocker] 数据权限(RBAC data_scope)在批量写路径上被绕过;多处 set_available 可直接改他人数据、甚至停用超管

位置

  • 根因:backend/app/core/base_crud.py
    • 权限条件只在读路径注入:_build_conditions(:300-330,其中 :316-318 调 Permission(...)._permission_condition())
    • 写路径直接用主键集合改库、完全不注入权限条件:delete(:225-232)、clear(:234-239)、set(:241-251)
  • 依赖「调用方自己先查一遍」的守卫,而这些调用方漏了或只做了一半:
    • backend/app/modules/system/notice/service.py:150-156 NoticeService.set_available:零校验直接 set(ids=data.ids, status=...)
    • backend/app/modules/system/dict/service.py:221-230(DictTypeService)与 :510-519(DictDataService):同样零校验
    • backend/app/modules/system/user/service.py:268-273 UserService.set_available:只对「查出来的」用户做 is_superuser 拦截,没有校验 len(users) == len(data.ids);越权 id 查不出来 → 不触发拦截 → 但 set(ids=data.ids) 照改 → 可用任意(含超管)id 启停
    • backend/app/modules/system/dept/service.py:90-105 DeptService.set_available:get_child_recursion 会无条件 ids.append(id)(backend/app/utils/common_util.py:192-196),所以请求里的 id 即使不在可见集合中也会进入 total_ids → set() 越权
    • backend/app/modules/task/cronjob/node/service.py:149-164 batch_set_status:if not obj: continue 后仍 set(ids=ids, ...)
  • 对照(说明这是「漏做」而非「设计」):role/service.py:195-209、position/service.py:86-92、notice/service.py:135-148(delete)、user/service.py:145-163(delete)都显式做了「按可见集合校验每个 id」的前置检查。

现象:一个 data_scope=1(仅本人)的普通管理员,只要拥有对应按钮权限(module_system:notice:patch、module_system:dict_type:patch、module_system:user:patch、module_system:dept:patch 等),就能通过 ids 参数修改任意租户/部门的公告、字典、部门状态;对 user:patch 还能一并停用超级管理员账号(get_list 因权限过滤看不到超管 → 绕过 is_superuser 拦截 → set(status=1) 生效)。

为什么有害:Permission 类的类注释自称「为业务模型提供数据权限过滤功能」,但实现只在 SELECT 上生效,导致「查不到 ≠ 改不了」。这类越权不需要任何注入技巧,普通管理员账号 + 一个正常业务接口即可完成,属可直接利用的授权缺陷;「能停用超管」还是提权/拒绝服务链的一环。

建议修法

  1. 把权限条件下沉到写路径:delete / set / clear 一律先经 _build_conditions(或构造 WHERE pk IN (...) AND <permission_condition>),从结构上让「越权 id 命中 0 行」;顺手用 rowcount != len(set(ids)) 报错。这样「Service 忘记校验」最多是功能异常,不再是越权。
  2. 过渡期先把上述 6 处调用点改成统一模板:objs = await crud.get_list(search={"id": ("in", ids)}) → if len(objs) != len(set(ids)): raise CustomException(...) → 再 set/delete(参考 role/service.py:195-209)。
  3. get_child_recursion / get_parent_recursion 增加 visited 集合(同时修 M5/H4),并先校验入参 id 在可见集合内再展开。
  4. 补一条集成测试:以非超管 + data_scope=1 登录,对越权 id 调 set_available/delete,断言 4xx 且目标行未被修改。

H1 [high] SECRET_KEY 有硬编码默认值;.env.example 未模板化该键;无启动期校验 → 生产可能用公开密钥签发 JWT

位置

  • backend/app/config/setting.py:66:SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production" # ...(必须通过环境变量 SECRET_KEY 设置,无默认值)——注释与代码自相矛盾,实际有默认值。
  • backend/env/.env.example:2 的注释明确要求使用者修改 SECRET_KEY,但文件正文里没有 SECRET_KEY 这一项(也没有 DATA_ENCRYPTION_KEY)。
  • 该密钥还被用来派生数据加密主密钥:backend/app/utils/crypto_util.py:52-56(DATA_ENCRYPTION_KEY or SECRET_KEY → HKDF)。
  • 相关反证:tests/conftest.py:33 注释写「SECRET_KEY 必填项,固定注入保证测试确定性」——说明作者本意是「必填」,但 Settings 给了默认值,测试才需要手动注入。

现象:按仓库自带 .env.example 部署(不额外设置 SECRET_KEY)时,进程用公开已知的默认串签发/校验 JWT,并用它派生 Fernet 主密钥。

为什么有害:任何人可用默认串自行签发 {"sub": "<任意 session_id>"} 的 token(app/core/security.py:99-116,HS256);虽然还需要 Redis 里存在对应 session(app/core/dependencies.py:114-118),但数据加密(存储源密码、AI 密钥,crypto_util.py)可被离线解密。此外默认值被写进代码仓库,属于「默认凭据」类配置缺陷。

建议修法

  1. SECRET_KEY: str = "",在 Settings 上加 model_validator(mode="after"):仅当 ENVIRONMENT != dev 时空值/等于默认串就直接 raise(fail-fast),或至少在 prod 打 logger.critical 并拒绝启动。
  2. .env.example 补 SECRET_KEY = (并注释生成方式 openssl rand -hex 32)与 DATA_ENCRYPTION_KEY。
  3. 把「默认串」改为不可能出现的哨兵值(如 __CHANGE_ME__),避免有人以为它是可用的密钥。
  4. 统一 model_config.env_file 与 get_settings() 的 ENVIRONMENT 默认值(setting.py:17 用 os.getenv('ENVIRONMENT')(无默认)而 :326 用默认 'dev'),避免 .env.None 这种隐式路径。

H2 [high] 定时任务「代码块」默认允许 exec,且带完整 builtins

位置

  • 开关默认开:backend/app/config/setting.py:127:SCHEDULER_ALLOW_CODE_EXEC: bool = True
  • 执行点:backend/app/core/ap_scheduler.py:544-573,关键两行 module.__dict__["__builtins__"] = __builtins__(:562)与 exec(code_block, module.__dict__)(:563)
  • 代码块来源是业务表单字段:backend/app/modules/task/cronjob/node/service.py:281-285(code_block = job_info.func)、:311/:330(作为 job args 注册)、:364-376(手动执行)

现象:任何能创建/编辑「节点任务」的用户提交 func 即可在服务器上以应用进程权限执行任意 Python(无沙箱、无 AST 白名单、builtins 完整)。

为什么有害:与 B1 同类(认证用户 → RCE),且默认开启、只在注释里写「生产环境强烈建议设为 False」。多租户/多管理员后台里,这是「任务管理」权限被偷偷放大成「服务器控制权」。

建议修法

  1. 默认值改 False;生产启动时若为 True 打印 logger.critical。
  2. 改为白名单「内置处理器注册表」(node service 已有 handlers/ 目录,如 demo_handler.py),func 只允许引用注册名;彻底删掉 exec 路径,或至少用 AST 校验(禁 import/__/属性访问)。
  3. 若必须保留,放到独立执行器/子进程 + 降权用户 + 资源/网络限制,并单独授予权限标识。

H3 [high] 操作日志的操作用户恒为 unknown(审计链断裂)

位置:backend/app/core/router_class.py:119

"username": str(getattr(getattr(request.state, "ctx", None), "user_username", None) or "unknown"),

全仓库检索:request.state.ctx 只有这一处读取,没有任何一处写入(grep -rn "request\.state" app 仅命中此文件)。同时日志记录体里也没有 created_id(OperationLogRecord,:44-56),而导出模板却包含 created_id 列(backend/app/modules/system/log/service.py:118-130)。

现象:所有 task_operation_log/operation log 记录的 username 都是 unknown,「谁改了什么」无法追查。

为什么有害:对后台管理系统,操作日志是唯一的追责证据来源(也是等保/审计要求的常见项)。当前它只能证明「某个请求发生过」,不能证明「谁发起」。B2 那种越权行为发生后,日志无法定位到账号。

建议修法:在 AuthPermission.__call__ / get_current_user(app/core/dependencies.py:206-234、:43-49)里把认证后的 auth.user.username、user_id 写入 request.state(注意 AuthSchema.user 带 exclude=True,取 auth.user.username 即可);OperationLogRecord 增加 created_id/user_id 字段并在 _write_operation_log_async 中落库;补一条测试断言登录用户的操作日志 username 非 unknown。


H4 [high] 部门/菜单父子关系可造环 → get_child_recursion 无限递归(RecursionError / 500),并可被用来打爆自己的数据权限路径

位置

  • 递归无 visited 保护:backend/app/utils/common_util.py:181-196(get_child_recursion)、:141-155(get_parent_recursion 同型)
  • 可造环的写入口(都不校验 parent 是否为自己/后代):
    • backend/app/modules/system/dept/service.py:62-72(update 只查重名/编码)
    • backend/app/modules/system/menu/service.py:98-119(_validate_parent_child_type 只校验父的 type,X.parent_id = X(type=1)可通过)
  • 被环命中的调用点:app/core/permission.py:119-124(_load_dept_children,每个使用 dept 数据权限的请求都会走到)、app/modules/system/dept/service.py:100-103、app/modules/system/menu/service.py:133-148(delete/set_available)

现象:把部门 A 的 parent_id 设成 A(或 A↔B 互指)后,任何 data_scope=2 用户的下一次查询、或对该节点执行删除/启停,都会 RecursionError: maximum recursion depth exceeded(500)。菜单同理。

为什么有害:一个纯配置操作即可让整个部门数据权限链路报 500(不是单点功能坏,而是所有带 created_id 模型的读路径),属低成本自伤/拒绝服务;且脏数据持久化后即使重启也立刻复发。

建议修法:update 时校验 data.parent_id != id 且 data.parent_id not in get_child_recursion(id, ...);两个递归函数加 visited 集合与深度上限(超限抛 CustomException);在 Alembic 迁移或启动自检里检测并告警存量环。


H5 [high] 异常处理器里的 status.HTTP_403_SERVICE_UNAVAILABLE 不存在 → 处理器自身抛 AttributeError

位置:backend/app/core/exceptions.py:119

if "connect" in detail or "connection" in detail:
    return ErrorResponse(msg="数据库连接失败", status_code=status.HTTP_403_SERVICE_UNAVAILABLE, data=expose_detail)

实测(.venv/bin/python):starlette.status 里 HTTP_403_SERVICE_UNAVAILABLE 不存在(只有 HTTP_403_FORBIDDEN 与 HTTP_503_SERVICE_UNAVAILABLE),'HTTP_403_SERVICE_UNAVAILABLE' in dir(status) → False。ruff 不会发现(属性访问)。

现象:该分支一旦命中,AttributeError 在 ASGI 异常处理中间件里被抛出 → Starlette 兜底返回纯文本 500,既没有统一 JSON 信封,也没有可读原因(logger.error 在本行之后,也不会执行)。

为什么有害:这正是「数据库连不上」这个最需要清晰报错的场景,却退化成最难排查的 500 裸响应;同时说明这段代码从未被执行过(无覆盖)。另外该分支挂在 isinstance(exc, IntegrityError) 下(:115),而连接失败实际是 OperationalError/InterfaceError,即使名字修对了也永远不会命中,属双重失效。

建议修法:改成 HTTP_503_SERVICE_UNAVAILABLE,并把连接类错误判断从 IntegrityError 分支上移到 SQLAlchemyError 顶层(或在 OperationalError 分支里处理);补一个「DB 不可达 → 503 JSON 信封」的测试(可用 pytest.raise/mock 引擎)。


H6 [high] 上传:内容校验只记日志不拦截;默认允许 .svg 且静态同源提供 → 存储型 XSS;file.size 为 None 时无大小上限

位置

  • backend/app/utils/upload_util.py:216-234(validate_file_content_type:类型不匹配只 logger.warning,始终 return True)
  • 默认白名单含 .svg、.ico、.xls/.xlsx:backend/app/config/setting.py:213-222
  • 上传目录通过静态路由同源暴露:app/__init__.py:88-91(app.mount("/static", StaticFiles(directory=STATIC_DIR))),落地目录 settings.UPLOAD_FILE_PATH = Path("static/upload")(setting.py:211)
  • 大小校验依赖 file.size:upload_util.py:249-251(if file.size and file.size > MAX_FILE_SIZE),随后 :421 await file.read() 整文件读入内存

现象:上传一个内容为 <svg xmlns="…"><script>fetch('/api/v1/system/user/current/info',{headers:{Authorization:'Bearer '+localStorage.token}})</script></svg> 的 .svg(或把 .html 改名成 .png 绕过扩展名白名单——内容校验不拦),即可从 API 同源地址访问到它。detect_file_type(:170-193)根本不识别 SVG/HTML,所以 SVG 必然通过。前端 token 存于浏览器(Authorization 头),同源脚本可直接读取 → 管理员打开该 URL 即被接管。若经过代理导致分片传输使 file.size 为 None,则完全没有大小限制,且 await file.read() 把整个文件读进内存。

为什么有害:文件上传是后台系统里最常见的 XSS/RCE 落地通道;同源 + 无 Content-Disposition(静态挂载直接内联渲染 SVG)使 XSS 可靠触发,且可窃取会话。

建议修法

  1. 内容校验改为强制:detect_file_type 无法识别或与声明扩展名不一致 → 抛 CustomException;补齐 SVG/HTML 魔数识别与内联白名单。
  2. 从 ALLOWED_EXTENSIONS 移除 .svg(或对 SVG 做 DOMPurify/白名单化清洗后再存)。
  3. 上传目录改为独立子域/独立静态目录,响应加 Content-Disposition: attachment + X-Content-Type-Options: nosniff。
  4. 大小限制不依赖 file.size:流式读取时累加字节并在超限时中止;MAX_FILE_SIZE 同时用 Content-Length 预检。

2. 中等(medium)

M1 [medium] order_by 完全由客户端控制且无字段白名单

  • 位置:backend/app/core/base_schema.py:141-162(order_by: Any,仅承诺「JSON 数组」),解析处 backend/app/core/base_crud.py:322-330(_parse_order:getattr(self.model, field) → asc/desc(...))
  • 现象:order_by=[{"任意属性名":"asc"}] 进入 getattr;传 __table__/metadata/关系属性等会抛 ArgumentError,被 page() 的外层 except 包成 CustomException(msg="分页查询失败: ...")(默认 HTTP 500,exceptions.py:41-55),对客户端表现为「服务器错误」而非 400。
  • 为什么有害:① 用户输入错误 → 500 而非 400,污染错误监控;② 可按未建索引列排序(如 description)造成慢查询,是低成本的接口级 DoS;③ 无白名单意味着模型新增敏感列后立刻对所有人开放排序侧信道。
  • 建议修法:在 PaginationQueryParam.validate_order_by 里只接受 {field: asc|desc} 且 field 匹配 ^[a-z_][a-z0-9_]*$;在 _parse_order 里用 sa_inspect(model).columns 白名单校验,非法字段抛 400 而不是 500。

M2 [medium] 数据权限判定在每个读操作上额外查库,且 _load_dept_children 全表加载

  • 位置:backend/app/core/permission.py:44(_load_user_data_scopes,每次 _build_conditions 都执行)、:96-103(select(RoleModel.data_scope).join(RoleModel.users))、:119-124(select(DeptModel) 全表 + get_child_recursion 内存展开)
  • 现象:get/count/exists/get_list/page 各触发 1 次权限查询;data_scope=2 时再全表扫一次部门。exists() 走 count()(base_crud.py:88-90)→ 一次「存在性判断」= 权限查询 + COUNT。
  • 为什么有害:每个请求多 1–2 次 DB 往返;部门表大时每次查询都全量加载 + Python 递归,随规模线性恶化;也让 page() 这类本应 2 条的 SQL 变成 4 条。
  • 建议修法:在 AuthSchema/Redis 会话里缓存一次「用户可见部门集合」(会话内不变,可直接放到登录时写入的 Redis session 或 request.state);把部门子树改为单条递归 CTE(MySQL 8 / PostgreSQL 均支持)或按 parent_path 物化路径过滤。

M3 [medium] 「建表 SQL」只校验顶层语句类型 → 放行 CREATE TABLE ... AS SELECT、CREATE VIEW、CREATE INDEX

  • 位置:backend/app/modules/generator/gencode/service.py:391-404(has_create = any(isinstance(s, Create) ...);禁止集合只覆盖 Delete/Drop/Insert/TruncateTable/Update 顶层节点)、:430-444(只重放 Create/Comment/Alter)
  • 现象:CREATE TABLE x AS SELECT * FROM sys_user(CTAS)在 sqlglot 里是 Create,内层 Select 不会被禁止集合命中 → 直接执行,生成含敏感数据的副本表;CREATE VIEW/CREATE INDEX 同样被当作「建表」放行。
  • 为什么有害:该接口的本意是「只允许建表」,实际打开了「以数据库权限复制任意表数据」「创建视图指向任意表」的口子;配合 db/list 导入还能把该表变成代码生成对象。
  • 建议修法:只接受 isinstance(s, Create) 且 s.kind.upper() == "TABLE" 且 s.expression 为空(无 CTAS);Comment 只允许 TABLE/COLUMN;拒绝一切非 TABLE 的 Create;执行前对重放后的 SQL 再做一次同规则校验。

M4 [medium] 代码生成/字典删除存在 N+1 写放大

  • 位置:backend/app/modules/generator/gencode/service.py:349-368(导入:每列 1 条 INSERT)、:487-509(编辑:每列 1 条 UPDATE/DELETE,含 delete(ids=[db_id]) 逐列)、:799-830(每个按钮 1 次 get + 1 次 create,固定 9 个按钮 → 最多 18 条语句)
  • 另见 backend/app/modules/system/dict/service.py:200-203(每个 dict_type 一次 get_list 判空)
  • 现象:60 列表格导入 = 60 次 flush+refresh(base_crud.create 每次都 flush() + refresh() + 可能的 joinedload 回查,base_crud.py:186-217),一次「生成代码」= 十余条 INSERT。
  • 为什么有害:整个请求共用一个事务(dependencies.py:21-28),语句数线性放大延长锁持有时间;MySQL/PostgreSQL 下大表导入/生成代码会明显变慢并加剧锁竞争。
  • 建议修法:批量 add_all / insert().values([...]),必要时用 executemany;create 的 refresh 改为按需;按钮权限用 permission in (...) 一次查出已存在集合再差量创建;字典删除用 exists() 子查询或一次 IN 查询。

M5 [medium] 时区口径混用:模型默认 UTC-aware,业务代码用 naive datetime.now()

  • 位置:UTC 一侧 backend/app/core/base_model.py:104,111-112(created_time/updated_time 默认 datetime.now(UTC))、backend/app/core/base_crud.py:64(软删除 datetime.now(UTC));本地时间一侧 backend/app/modules/system/user/crud.py:40(last_login = datetime.now())、backend/app/modules/system/log/service.py:146(cutoff = datetime.now() - timedelta(days=days))、backend/app/modules/system/auth/service.py:372,479(JWT exp 基线)
  • 现象:created_time(UTC 墙钟写入)与 last_login(本地墙钟写入)在同一张 sys_user 表里混着存;cleanup_expired_logs 用本地时间与 UTC 墙钟比较。
  • 为什么有害:① 日志清理边界偏移一个时区(以东八区为例多删 8 小时数据,或保留策略形同虚设);② 「最后登录时间」与「创建时间」相差 8 小时,前端展示与运维排查都会误判;③ 任何跨时区迁移/多活部署都会放大。
  • 建议修法:统一为「存 UTC、展示时按配置时区格式化」(DateTime(timezone=True) + datetime.now(UTC) 全量替换 naive 调用),前端序列化已由 validator.py:DateTimeStr 统一;补一条测试断言 last_login 与 created_time 同源。

M6 [medium] RedisCURD.get_keys 走 KEYS 且被用于全库匹配

  • 位置:backend/app/core/redis_crud.py:89-102(KEYS pattern),调用点 backend/app/modules/monitor/cache/service.py:35,50,57,64(其中 :64 是 get_keys() → KEYS *)
  • 现象:缓存管理页面点「清除所有缓存」会对整个 Redis 实例执行 KEYS *。
  • 为什么有害:KEYS 在单线程 Redis 上 O(N) 阻塞,会拖慢/卡死所有依赖 Redis 的请求(会话、限流、调度 jobstore);生产数据量大时是明确的可用性事故点。
  • 建议修法:改 SCAN(类内已有 scan_keys,redis_crud.py:70-87),或分页 + 分批 UNLINK;KEYS 路径加显式弃用告警。

M7 [medium] 在线用户列表 N+1 + zip(strict=True) 在 Redis 出错时抛未捕获异常

  • 位置:backend/app/modules/monitor/online/service.py:23-25(scan_keys → mget → zip(keys, tokens, strict=True)),循环内 :35 每个会话一次 GET,异常分支 :59-62 每个坏会话 3 次 DELETE
  • 现象:列表中每个会话 1 次 Redis 往返(外加失效会话 3 次删除);mget 内部异常时返回 [](redis_crud.py:59-68),strict=True 使 zip 抛 ValueError → 未捕获 → 500。
  • 为什么有害:会话多时页面变慢;Redis 抖动时该接口 500 而不是降级。
  • 建议修法:mget 失败返回 None 并显式判空;用 Redis pipeline / MGET 一次取齐所有 USER_SESSION(mget 后逐个 json.loads);失效会话用一次 DEL 多键删除。

M8 [medium] 测试覆盖实质为零,且 assert_route 把「异常/500」当成通过

  • 位置:backend/tests/(3 个文件共 324 行,2 个用例)、backend/tests/conftest.py:243-277(assert_route:except Exception: return 直接放过,expected_status=None 时只断言 != 404)
  • 现象:2.85 万行代码只有「健康检查」和「迁移零漂移」两个断言的守护;assert_route 这个为「模块化接口测试」准备的助手没有任何测试使用(grep -rn assert_route tests 只命中定义),而它的语义是「只要不是 404 就算过」。
  • 为什么有害:本报告里 B1/B2/H1–H6 全部可以在 CI 无感地存在;任何重构(尤其是插件/生成器/权限)都没有安全网。
  • 建议修法:① 至少为「认证→权限→CRUD」主链路补 happy path + 越权 4xx 用例(assert_route 的 expected_status 必填);② 把 except Exception: return 改为失败;③ 加 pytest --cov --cov-fail-under(当前无覆盖率门禁)。

M9 [medium] 用户角色/岗位「替换」语义在空列表时静默跳过

  • 位置:backend/app/modules/system/user/service.py:180-204(_set_user_roles / _set_user_positions 开头 if not role_ids: return),调用点 :112-113、:140-141(data.role_ids or [])
  • 现象:编辑用户时提交 role_ids: [](本意「清空所有角色」)不会清空;同一函数被 delete()(:166-167)复用且那里传 role_objs=[] 是有效的——同一语义两种行为。
  • 为什么有害:管理员以为已回收权限,实际账号仍持有旧角色(最小权限原则失效),属静默的权限残留;也是审计时「配置与预期不符」的常见来源。
  • 建议修法:区分「未提供」(None → 保持)与「提供空列表」([] → 清空),签名改为 role_ids: list[int] | None;补测试固定这两种语义。

M10 [medium] 配置/生命周期:引擎在 import 期创建、上传目录相对 CWD、WORKERS>1 未经真实多进程验证

  • 位置:backend/app/core/database.py:87-88(模块级 engine = create_sync_engine()、async_engine, async_db_session = create_async_engine_and_session())、backend/app/config/setting.py:33(WORKERS)、:211(UPLOAD_FILE_PATH: Path = Path("static/upload") 相对路径)、backend/app/modules/common/file/service.py:52-56(用同一相对根做 is_relative_to 校验)
  • 现象:只要 import app.core.database(很多模块间接依赖)就会建立两个引擎;uvicorn --factory app:create_app 在 pre-fork(多 worker)场景下,若在 fork 前 import 就会把父进程的连接池句柄带进子进程(经典 fork 连接复用问题)。上传目录以进程 CWD 为基准解析。
  • 为什么有害:① 从不同 CWD 启动(systemd WorkingDirectory=/、容器 workdir 变更)会让上传文件落到别处、download 校验根也随之变化,出现「上传成功但下载 404/非法路径」;② 多 worker 下的连接池/调度器行为未验证,WORKERS>1 的注释(setting.py:33)只写了「确保 Redis 共享 jobstore」。
  • 建议修法:引擎改为 lifespan 内惰性创建(或用 @lru_cache 函数延迟到首次使用);UPLOAD_FILE_PATH 改为基于 path_conf.BASE_DIR 的绝对路径;在文档/启动日志中固化「多 worker 部署须用 app:create_app factory 且不要在 fork 前 import DB 模块」。

M11 [medium] OAuth 回调域名白名单默认关闭;X-Real-IP 在私网来源下被无条件信任

  • 位置:backend/app/config/setting.py:150(OAUTH_ALLOWED_HOSTS: list[str] = ["*"])、backend/app/modules/system/auth/oauth_service.py:35-43(if allowed_hosts and allowed_hosts != ["*"]: 才校验 Host)、backend/app/utils/ip_local_util.py:44-50(TRUSTED_PROXY_HOPS > 0 且对端是私网/回环时直接采用 X-Real-IP)、setting.py:34(TRUSTED_PROXY_HOPS = 1 默认)
  • 现象:默认配置下 redirect_uri 由 request.base_url(即 Host 头)拼出,Host 注入不受任何校验;只要直连对端落在私网(docker/k8s 内网、跳板机、同网段主机),客户端就能伪造 X-Real-IP 影响登录限流与 IP 黑白名单(app/core/middlewares.py:43-58、auth/service.py:161)。
  • 为什么有害:OAuth 授权码可能被重定向到攻击者域名;伪 IP 可直接绕过登录限流(app/modules/system/auth/service.py:161-163 还额外豁免 127.0.0.1,见 M12)与黑名单封禁。
  • 建议修法:OAUTH_ALLOWED_HOSTS 默认改为部署域名(或改为「必须显式配置否则拒绝启动」);私网来源不能作为信任代理的判据,应改为显式代理 IP/CIDR 白名单(TRUSTED_PROXIES);启动日志打印生效的信任策略。

M12 [medium] 登录限流可被绕过:豁免 127.0.0.1、仅按 IP、成功后不复位

  • 位置:backend/app/modules/system/auth/service.py:161-163(if not request_ip or request_ip in ("unknown", "127.0.0.1", "localhost"): return)、:164-173(固定窗口 INCR)、:209-242(失败/成功分支)
  • 现象:get_client_ip 从不返回字面量 "unknown"(ip_local_util.py:35-60),而配合 M11 的 X-Real-IP 信任,攻击者可直接把来源设成 127.0.0.1 → 完全不进入限流计数;此外计数只按 IP、永不按账号,成功后也不清零,分布式撞库(多 IP 打同一账号)无任何约束。
  • 为什么有害:爆破/撞库防护实际上可被一行请求头绕过;LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10 只能挡住最笨的脚本。
  • 建议修法:删除 127.0.0.1/localhost 豁免(排障可另设开关);增加「按账号」计数与指数退避;成功登录后清零该账号计数;把「未知来源」当作普通 IP 计数而不是豁免。

M13 [medium] execute_sql 吞掉异常返回 False,DDL 部分成功无法感知

  • 位置:backend/app/modules/generator/gencode/crud.py:331-346(except Exception: logger.error(...); return False),调用点 backend/app/modules/generator/gencode/service.py:443-444
  • 现象:多条 DDL 逐条执行(:430-444),任一条失败只返回 False → 外层抛自定义异常,但前面已成功的语句不会回滚(MySQL DDL 隐式提交)。
  • 为什么有害:用户看到「创建表结构失败」,库里却残留半张表;下次重试因「表已存在」被拒(service.py:421-422),需要人工清理;服务层也丢失了真实错误类型(只剩一句 请检查数据库)。
  • 建议修法:execute_sql 抛原始异常(由 handle_service_exception 包装),错误信息带语句序号与方言原文;对 MySQL 显式提示「DDL 不可回滚,失败可能残留半成品」,并提供清理/重试入口。

3. 低(low)

L1 [low] 错误响应/错误码在同一种语义下不一致

  • backend/app/core/dependencies.py:228 用 RET.FORBIDDEN(403),:232 同为 403 却用 RET.NO_PERMISSION(10403);backend/app/common/response.py:70-78 的 ErrorResponse 默认 code=RET.ERROR.code=1,于是 HTTPException 分支(exceptions.py:76-85)永远返回 code=1 + 各自 status_code;CustomException 默认 code=-1(exceptions.py:41-55)。
  • 前端若按 code 分支处理会踩坑(同一语义两个码)。建议:错误码与 HTTP 状态一一映射,统一由一个映射表产出;ErrorResponse 默认 code 由 status_code 推导。

L2 [low] require_superadmin 是死代码,且权限不足时返回 500 而非 403

  • backend/app/core/exceptions.py:16-38:全仓库无调用点(grep -rn require_superadmin app 只命中定义),且 CustomException(msg="仅平台管理员可操作") 使用默认 status_code=500。
  • 建议:删除或改为 status_code=403, code=RET.FORBIDDEN.code;若保留,补使用点与测试。

L3 [low] AuthPermission 不识别用户侧的 *:*:* 通配

  • backend/app/core/dependencies.py:219-232:通配判断作用在接口声明的 self.permissions 上;用户权限集合里即使含 *:*:*(RuoYi 习惯写法),:230 的 any(perm in user_permissions ...) 仍会拒绝。
  • 影响:给非超管角色授 *:*:* 菜单后「勾了权限却全 403」(fail-closed,安全但难排查)。建议:显式支持 user_permissions 中含 * / *:*:* 时放行,并在权限说明里标注通配符语义。

L4 [low] 文档/常量与实际实现不一致(易误判数据权限语义)

  • backend/app/core/base_model.py:47-56 写「1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义」;实际实现是 app/core/permission.py:16-18 的 1/2/3 = 仅本人/本部门及以下/全部(前端 frontend/web/src/views/module_system/role/components/FaPermissonDrawer.vue:37-39 与 role/schema.py:25-30、role/model.py:72 均为 1..3)。
  • backend/app/core/validator.py:300-330(role_permission_request_validator)仍按 1..5 校验并生成错误文案(因 RolePermissionSettingSchema 已 le=3,该分支为死代码)。
  • backend/app/modules/system/role/model.py:41 称 sys_role_depts「仅当 data_scope=5 时使用」,与 role/service.py:163-165(改数据权限时直接清空 dept 关联)矛盾。
  • backend/app/modules/system/log/service.py:118-130 导出映射含 created_id,但 OperationLogRecord/模型无该字段(导出为空列)。
  • 建议:统一按 1..3 修正注释与死代码;删除 sys_role_depts 相关残留描述(或补齐「自定义数据权限」实现)。

L5 [low] _effective_package_name 从 menu.route_path 直接赋给 package_name,绕过 pydantic 校验器

  • backend/app/modules/generator/gencode/service.py:88-98(return seg if seg.startswith("module_") else f"module_{seg}")与 :649、:1245 直接赋值给已校验过的模型字段(pydantic 的 field_validator 不会因赋值再跑一次)。
  • 现状:由于 module_ 前缀约束,实测难以构造真正的路径穿越(route_path 首段永远是单段),但生成路径的合法性完全依赖调用方,属于脆弱点。
  • 建议:抽一个 _safe_slug() 在赋值后再规范化一次,并断言结果匹配 ^module_[a-z0-9_]+$;generate_code 落盘前用 Path.resolve().is_relative_to(允许根) 复核(可复用 upload_util.py:454 的写法)。

L6 [low] 在线用户「强制下线」无归属校验(管理员作用域内的 IDOR)

  • backend/app/modules/monitor/online/service.py:69-78(delete_online(redis, session_id) 直接删会话),session_id 来自客户端参数;受 monitor:online:delete 权限保护。
  • 影响:在管理员之间无隔离(A 管理员可踢掉 B 管理员的会话)。建议:普通管理员仅能踢本部门/自己创建的下线范围,或至少记录操作日志(当前操作日志 username 恒为 unknown,见 H3,等于无痕)。

4. 按优先级排序的修复清单

优先级 编号 一句话动作 影响面 预估成本
P0 B1 代码生成模板对 function_name/column_comment/column_name 做转义与字符集校验;生成后 ast.parse 校验;产物移出 app/plugin 自动导入路径 服务器 RCE 1–2 天
P0 B2 把 Permission 条件下沉进 base_crud 的 delete/set/clear;并修 6 处未校验的 set_available(notice/dict×2/user/dept/node) 越权写、可停用超管 1–2 天
P0 H1 SECRET_KEY 去掉可用默认值 + prod 启动校验;.env.example 补模板项 JWT 伪造 / 数据可解密 2–4 小时
P0 H2 SCHEDULER_ALLOW_CODE_EXEC 默认改 False,exec 换成处理器注册表 认证用户 RCE 0.5–1 天
P0 H4 部门/菜单父子加环检测;get_child_recursion/get_parent_recursion 加 visited + 深度上限 全量读路径 500 2–4 小时
P0 H3 认证后把 username/user_id 写入 request.state,操作日志落库并补 created_id 审计不可用 2–3 小时
P1 H5 修 HTTP_403_SERVICE_UNAVAILABLE → 503,并把连接错误分支移出 IntegrityError 故障期无可用报错 1 小时
P1 H6 上传内容类型强校验 + 去掉 .svg 内联 + 流式大小限制 + nosniff/attachment 存储型 XSS / OOM 0.5–1 天
P1 M12 去掉 127.0.0.1 限流豁免,加账号维度计数与成功后清零 爆破防护形同虚设 2–3 小时
P1 M11 OAuth 域名白名单默认收紧;私网来源不再等价于「可信代理」 重定向劫持 / IP 伪造 3–4 小时
P1 M13 execute_sql 抛原始异常,DDL 失败给出「不可回滚」提示与清理入口 半成品表残留 2 小时
P2 M1 order_by 白名单校验,非法字段返回 400 500 噪音 + 排序 DoS 2–3 小时
P2 M8 CI 加主链路 + 越权用例,assert_route 不再吞异常,加覆盖率门禁 所有回归的护栏 1–2 天
P2 M2 权限/可见部门集合按请求缓存一次,部门子树用递归 CTE 每请求多 1–2 次查询 0.5–1 天
P2 M9 role_ids/position_ids 区分 None 与 [] 权限残留 1–2 小时
P2 M4 代码生成/字典删除改批量写 事务时长与锁竞争 0.5–1 天
P2 M3 建表 SQL 只放行 Create(kind=TABLE) 且无 CTAS/VIEW/INDEX 数据复制面 2–3 小时
P2 M5 时间统一「存 UTC」;修 cleanup_expired_logs 与 last_login 日志保留 & 展示错乱 3–4 小时
P3 M6 get_keys 换 scan_keys/UNLINK Redis 阻塞 1–2 小时
P3 M7 在线列表改 MGET + 判空,去掉 zip(strict=True) 崩溃点 页面变慢 / 500 2 小时
P3 M10 引擎改 lifespan 惰性创建;UPLOAD_FILE_PATH 改绝对路径 多 worker / CWD 依赖 半天
P3 L1–L6 错误码统一、删死代码、修过期注释、_effective_package_name 再规范化、在线下线归属校验 可维护性/排查成本 1 天

建议的落地顺序(最小爆炸半径):

  1. 第一批(当天可完成):H1、H5、H3、H4、M12 — 都是低风险小改动,立刻消掉「密钥/审计/500/环/限流」五类问题。
  2. 第二批:B2(权限下沉,改完后 6 处 service 的临时校验可保留但不再是唯一防线)、M1、M9。
  3. 第三批:B1 + H2 + H6(三处「权限被放大成 RCE/XSS」的能力收口,需要产品侧确认能力边界与权限命名)。
  4. 第四批:M8 补测试护栏,其余 medium/low 按迭代消化。

5. 附:本轮确认「做得好」的部分(避免修复时误伤)

  • 分层与依赖方向守得住:app/api/v1/routers.py 只是路由总表;app/core 需要业务模型时一律函数内延迟导入并注明「守卫不变式 3」(dependencies.py:172、permission.py:98-99,121、router_class.py:60、ap_scheduler.py:219-220,358);app/utils、app/common 不反向依赖 modules;modules 不依赖 app.api。
  • 异步阻塞处理整体到位:同步 SDK/CPU 密集操作普遍经 asyncio.to_thread(excel_util.py:42,149、password_util.py:39,71、gencode/crud.py 的 _sync_*、storage/core/base.py:378-428 统一包装各协议适配器),monitor/server/service.py:27-28 连 socket.gethostbyname 都做了下沉。
  • 事务边界清晰:请求级单事务 db_getter(dependencies.py:21-28),CRUD 只 flush 不 commit(base_crud.py 类注释);后台任务/无上下文场景自建会话(router_class.py:58-66、log/service.py:139-155)。
  • 存储源凭据处理规范:password 落库前加密(task/storage/node/service.py:116-135)、返回模型显式排除并只暴露 has_password(node/schema.py:141-153)、未传新密码时保留旧密码 —— 与 H1 的 SECRET_KEY 形成鲜明对比,可作为整改模板。
  • 分布式调度选主设计完整:Redis 锁 + 续期 + 候选接管 + 退出释放(ap_scheduler.py:76-192),并有节点任务自愈对账(:210-268)。
  • 静态检查干净:ruff check 全量通过。