由 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 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
44 KiB
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),写入即触发重载执行,攻击窗口从「下次重启」缩短到「保存即生效」。
建议修法(任一都不够,建议全做)
- 生成模板改为数据驱动:
comment='...'用repr()/json.dumps()产出,标识符类字段先做^[a-z_][a-z0-9_]*$断言;function_name这类自由文本只允许出现在渲染时转义的位置(或干脆不写入源码,改成常量表)。 - 在
GenTableColumnSchema/GenTableSchema上补齐校验:column_name必须匹配^[A-Za-z_][A-Za-z0-9_]*$,column_comment/function_name禁止换行与引号(或长度上限 + 字符集白名单)。 - 落盘后用
ast.parse()校验生成的.py,语法不合法直接拒绝并拒绝写盘(可防未来新增模板的注入面)。 - 生成产物写入独立输出目录(如
backend/generated/,不进app/plugin),改为人工审核后移动;至少不要落在discover.py会自动 import 的路径下。 - 该能力单独拆分高权限(如仅超管),并在文档/权限名里写明「等价远程代码执行」。
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-156NoticeService.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-273UserService.set_available:只对「查出来的」用户做is_superuser拦截,没有校验len(users) == len(data.ids);越权 id 查不出来 → 不触发拦截 → 但set(ids=data.ids)照改 → 可用任意(含超管)id 启停backend/app/modules/system/dept/service.py:90-105DeptService.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-164batch_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 上生效,导致「查不到 ≠ 改不了」。这类越权不需要任何注入技巧,普通管理员账号 + 一个正常业务接口即可完成,属可直接利用的授权缺陷;「能停用超管」还是提权/拒绝服务链的一环。
建议修法
- 把权限条件下沉到写路径:
delete/set/clear一律先经_build_conditions(或构造WHERE pk IN (...) AND <permission_condition>),从结构上让「越权 id 命中 0 行」;顺手用rowcount != len(set(ids))报错。这样「Service 忘记校验」最多是功能异常,不再是越权。 - 过渡期先把上述 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)。 get_child_recursion/get_parent_recursion增加visited集合(同时修 M5/H4),并先校验入参 id 在可见集合内再展开。- 补一条集成测试:以非超管 +
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)可被离线解密。此外默认值被写进代码仓库,属于「默认凭据」类配置缺陷。
建议修法
SECRET_KEY: str = "",在Settings上加model_validator(mode="after"):仅当ENVIRONMENT != dev时空值/等于默认串就直接raise(fail-fast),或至少在 prod 打logger.critical并拒绝启动。.env.example补SECRET_KEY =(并注释生成方式openssl rand -hex 32)与DATA_ENCRYPTION_KEY。- 把「默认串」改为不可能出现的哨兵值(如
__CHANGE_ME__),避免有人以为它是可用的密钥。 - 统一
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」。多租户/多管理员后台里,这是「任务管理」权限被偷偷放大成「服务器控制权」。
建议修法
- 默认值改
False;生产启动时若为True打印logger.critical。 - 改为白名单「内置处理器注册表」(node service 已有
handlers/目录,如demo_handler.py),func只允许引用注册名;彻底删掉exec路径,或至少用 AST 校验(禁import/__/属性访问)。 - 若必须保留,放到独立执行器/子进程 + 降权用户 + 资源/网络限制,并单独授予权限标识。
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),随后:421await 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 可靠触发,且可窃取会话。
建议修法
- 内容校验改为强制:
detect_file_type无法识别或与声明扩展名不一致 → 抛CustomException;补齐 SVG/HTML 魔数识别与内联白名单。 - 从
ALLOWED_EXTENSIONS移除.svg(或对 SVG 做 DOMPurify/白名单化清洗后再存)。 - 上传目录改为独立子域/独立静态目录,响应加
Content-Disposition: attachment+X-Content-Type-Options: nosniff。 - 大小限制不依赖
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_appfactory 且不要在 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 天 |
建议的落地顺序(最小爆炸半径):
- 第一批(当天可完成):H1、H5、H3、H4、M12 — 都是低风险小改动,立刻消掉「密钥/审计/500/环/限流」五类问题。
- 第二批:B2(权限下沉,改完后 6 处 service 的临时校验可保留但不再是唯一防线)、M1、M9。
- 第三批:B1 + H2 + H6(三处「权限被放大成 RCE/XSS」的能力收口,需要产品侧确认能力边界与权限命名)。
- 第四批: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全量通过。