mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +00:00
basedpyright 报出的三处,逐条定位后发现都不是「标注问题」:
1) oss_adapter.py:70 —— 真运行时 bug
SDK 里 `Client.uploader` 是**方法**(`def uploader(self, **kwargs) -> Uploader`),
原写法 `self.client.uploader.upload_file(...)` 是在绑定方法上取属性,运行时必抛
AttributeError —— 即 OSS 上传链路从未成功过(生产未配 OSS 源,故未暴露)。
已改为 `self.client.uploader().upload_file(...)`,并按 SDK 签名核对
`upload_file(request, filepath, part_size=..., parallel_num=...)` 参数正确。
2) sftp_adapter.py:45 —— 缺主机校验
`config.host` 为 `str | None`(对象存储可不填),却直接传给 paramiko
`connect(hostname: str)`。已把 host 并入既有的用户名/密码守卫,
缺主机时返回业务异常 400「SFTP 存储源必须配置主机、用户名与密码」。
3) ftp_ftps_adapter.py:58 —— 同上
`_new_client()` 增加主机守卫(400「FTP 存储源必须配置主机地址」,并补 CustomException 导入);
顺带修复同文件 `_ImplicitFTP_TLS.connect` 覆写签名与 ftplib.FTP 不兼容
(timeout: float、source_address: tuple[str, int] | None),消除该文件最后一条 error。
验证:basedpyright 这三个文件 0 errors(修复前 4 条);ruff 全绿;pytest 97 passed;
运行时探针:缺主机 → 拦截为 400 业务异常;配主机 → 守卫放行并进入真实连接(ConnectionRefused 如期)。
202 lines
18 KiB
Markdown
202 lines
18 KiB
Markdown
# FastapiAdmin 全栈审计总报告
|
||
|
||
> 生成时间:2026-10-04
|
||
> 审计范围:后端(FastAPI)、Web 管理后台(Vue3)、移动端(uniapp)、部署编排(Docker/Nginx)、需求覆盖、测试与可验证性、交互与视觉一致性
|
||
> 参与:7 名审计成员并行执行 + 队长线上实测复核
|
||
> 方法:只读审查 + 真实命令执行(命令与退出码均记录在各分报告);线上结论均由对生产环境的实测得出
|
||
|
||
---
|
||
|
||
## 0. 一句话结论
|
||
|
||
**这是一个功能可用、但安全与质量债较重的单租户 RBAC 中后台**:认证、权限、代码生成等核心能力具备,但存在多类可被直接利用的问题(公开密钥可伪造令牌、超管弱口令、代码生成即 RCE、部署面暴露),同时需求文档声称的 SaaS 多租户能力在代码中零实现——需求文档与实际产品是两件事。
|
||
|
||
---
|
||
|
||
## 1. 覆盖范围与缺口(必须先看)
|
||
|
||
| 维度 | 已覆盖 | 未覆盖 / 存疑 |
|
||
| --- | --- | --- |
|
||
| 后端代码 | 216 个 py 文件 / 28519 行静态审查;`ruff check` 全量通过 | 需真实 MySQL/PostgreSQL/Redis 的行为未验证(本机三端口均不可达) |
|
||
| 前端 Web | 101 个 `.vue`;`type-check` exit 0;`vitest` 10/10 通过 | 无浏览器实机验证;无真机性能数据 |
|
||
| 移动端 uniapp | 14 个页面;`type-check` exit 0 | 未跑 `uni build` 各平台产物;无真机验证;0 测试 |
|
||
| 部署编排 | compose 校验、镜像构建、容器运行时、端口、证书、资源实测 | 未做真实压测;未验证灾备恢复演练 |
|
||
| 需求覆盖 | 逐行读 REQUIREMENTS.md Part1 + §11–§19 + §20–§22 + §28–§30 | §23–§27、§38–§40 依「代码零实现」这一可穷举事实下结论,未逐行读正文表格 |
|
||
| 测试 | pytest 与 vitest 真实执行 | 迁移链路 0 覆盖;无 CI |
|
||
|
||
**审计成员明确声明的抽样边界与存疑项**,见各分报告末尾章节;本报告不把抽样结论伪装成全量结论。
|
||
|
||
---
|
||
|
||
## 2. 必须先止血的问题(可被直接利用)
|
||
|
||
### 2.1 [blocker] 生产使用源码公开的默认 SECRET_KEY
|
||
|
||
- 证据:`backend/app/config/setting.py:66` 默认值硬编码;`docker/docker-compose.yaml`、`docker/.env.example`、`backend/env/.env.prod` 三处均无该键(grep 验证 + 线上容器 env 实测)
|
||
- 后果:
|
||
- `backend/app/core/security.py:114,139` 用该密钥签发/校验 JWT → 可离线伪造任意用户(含超管)令牌
|
||
- `backend/app/core/crypto_util.py:47-56` 用同一密钥经 HKDF 派生 `DATA_ENCRYPTION_KEY` → 落库的加密字段(API Key、存储源口令)可被解密
|
||
- 与 `/api/v1/monitor/online/list` 返回的 session_id 结合,可在不知道任何口令的情况下完成认证绕过
|
||
- 为什么严重:攻击者只需要拿到源码(或知道该默认字符串),不需要任何其他条件
|
||
|
||
### 2.2 [blocker] 种子超管账号使用公开弱口令
|
||
|
||
- 证据:安全审计经 PBKDF2 复算确认种子哈希对应 `123456`;`backend/app/scripts/initialize.py` 无环境判断,空库启动即写入;README 中公开该口令
|
||
- 受影响账号:`super`、`admin`(均为超管)
|
||
- 实测补充:种子账号的登录受滑块验证码保护,但安全审计判定该验证码**无实质人机校验**,不构成有效防线
|
||
|
||
### 2.3 [blocker] 代码生成模板导致权限即 RCE
|
||
|
||
- 证据:生成模板 `autoescape=False`,把 `function_name` / `column_comment` / `column_name` 原样写入 `.py`;输出目录为 `app/plugin/**`,而 `app/core/discover.py:52,74` 在启动时 import 该目录
|
||
- 后果:任何拥有代码生成权限的账号可通过构造字段注释写入任意 Python 代码,服务重启后执行
|
||
- 相关:`backend/app/modules/system/scheduler` 侧 `SCHEDULER_ALLOW_CODE_EXEC=True`(`setting.py:127`)+ `exec` 带完整 builtins(`ap_scheduler.py:562-563`)无需 gencode 权限同样构成 RCE 面
|
||
|
||
### 2.4 [blocker] 仓库当前**无法构建**
|
||
|
||
- 证据:`docker/backend/Dockerfile` 仍只 `COPY requirements.txt`,而依赖已拆分为 `backend/requirements/`;该目录**未被 git 跟踪**(`git ls-files backend/requirements` = 0)
|
||
- 后果:新克隆的仓库按文档构建必然失败;`deploy-artifacts.sh` 因其自带 Dockerfile 生成逻辑而绕过该问题,但这掩盖了仓库本身的破损
|
||
- 状态:本轮已将仓库 Dockerfile 改写为与新依赖结构一致(见 §4)
|
||
|
||
### 2.5 [high] 部署面暴露与错误默认值
|
||
|
||
| 项 | 证据 | 状态 |
|
||
| --- | --- | --- |
|
||
| API 文档公网可访问 | `/api/v1/docs`、`/redoc`、`/openapi.json` 实测 200 | 已修(nginx 仅放行内网) |
|
||
| MySQL/Redis/Backend 端口对公网开放 | 外部实测 3306/6379/8001 均可达 | 已修(绑定 127.0.0.1) |
|
||
| 生产 CORS 回落为 `["*"]` 且 `ALLOW_CREDENTIALS=True` | `setting.py:243-248` | 待修 |
|
||
| `FORWARDED_ALLOW_IPS="*"` | `docker-compose.yaml:119`;叠加后端端口曾暴露 → 可伪造 XFF 绕过限流与登录 IP 记录 | 待修 |
|
||
| 登录失败返回 HTTP 500 | 实测:验证码过期走 `custom_exception_handler` → 500(业务异常被当服务器错误) | 待修 |
|
||
|
||
---
|
||
|
||
## 3. 逻辑与契约缺陷(影响数据正确性)
|
||
|
||
1. **通知状态枚举两端冲突**(已复核):后端权威 `notice/model.py:16` = `0草稿/1已发布/2已归档`,移动端一致;Web 端 `views/module_system/notice/index.vue:116-117,189-190,254,403` 却是 `0启用/1停用` → 已归档公告在 Web 显示为灰色数字「2」,且筛选条件里不存在「已归档」。
|
||
2. **移动端刷新令牌格式不符**:app 发 `{refresh_token}` 对象,后端 `auth/controller.py:57` 为 `Body(str)` → 422;移动端 token 过期后必然强制登出。
|
||
3. **移动端文件上传路径错误**:app 调 `/file/upload`,真实路径为 `/api/v1/common/file/upload` → 404。
|
||
4. **移动端生产 WebSocket 指向 localhost**:`app/.env.production:23` = `ws://localhost:5180/ws` → 线上 AI 对话不可用。
|
||
5. **富文本默认上传地址畸形**:`web/src/components/forms/fa-wang-editor/index.vue:83` 拼出 `//file/upload`(协议相对 + 缺 `/common`)→ 公告/工单插图必失败。
|
||
6. **Web 生产构建开关恒 false**:`web/vite.config.ts:28` 判断 `mode === "prod"`,而脚本只传 `production/development` → minify、drop_console、gzip/brotli、剔除 vue-devtools 全部不生效。
|
||
7. **CRUDBase 权限注入不完整**:`base_crud.py:300-330` 仅读路径注入数据权限,`delete/clear/set`(`:225-251`)不注入;6 处 `set_available` 无完整性校验 → 普通管理员可改任意部门/字典/公告、可停用超管。
|
||
8. **部门/菜单成环导致权限查询 500**:`common_util.py:181-196` 的 `get_child_recursion` 无 visited 集合。
|
||
|
||
---
|
||
|
||
## 4. 本轮已落地的修复(部署侧,全部有验证证据)
|
||
|
||
| 修复 | 证据 |
|
||
| --- | --- |
|
||
| 关闭公网数据面暴露:3306/6379/8001 仅绑 127.0.0.1 | 外部实测三端口均已关闭;四端 200 |
|
||
| API 文档对外封堵(仅内网可访问) | 外部 403,服务器本机 200,业务接口不受影响 |
|
||
| 容器改为非 root 运行(uid=1000 app) | 容器内 `id` = app;日志/上传/迁移目录均可写 |
|
||
| TLS 私钥与 `.env` 权限收紧到 600 | `stat` 实测;nginx 仍可读取 |
|
||
| 隐藏文件不再对外服务 | `.DS_Store` 由 200 变 403,并加 nginx deny 兜底 |
|
||
| 静态资源长缓存 | 哈希资源 `max-age=31536000, immutable`,图片 30 天,HTML 不缓存 |
|
||
| nginx 升级 1.25.5 → 1.27.5 | 配置校验通过;TLS1.2/1.3 正常;保留 `nginx:rollback-1.25` |
|
||
| 后端健康检查修复 | 由长期 unhealthy 变为 healthy(`ALLOWED_HOSTS` 放行 localhost) |
|
||
| 后端依赖拆分(核心 / 数据库驱动 / 存储) | MySQL 变体镜像 565MB → 521MB;PG 变体自检 asyncpg+psycopg 通过 |
|
||
| 部署脚本具备备份、回滚、轮转、变体构建 | `deploy-artifacts.sh` 各子命令均实测通过(含回滚哨兵测试) |
|
||
| SSH 加固 | 密码登录禁用、root 仅密钥、密码已轮换 |
|
||
|
||
---
|
||
|
||
## 5. 修复路线(按风险×成本排序)
|
||
|
||
| 优先级 | 动作 | 影响面 | 预估 |
|
||
| --- | --- | --- | --- |
|
||
| P0 | 注入强随机 `SECRET_KEY` + `DATA_ENCRYPTION_KEY`;轮换 super/admin 口令;若启用演示模式则关闭 | 会话作废需重新登录;落库密文保持可解 | 30 分钟 |
|
||
| P0 | 修仓库 Dockerfile 与依赖跟踪,使「克隆即可构建」 | 无运行时影响 | 20 分钟 |
|
||
| P1 | 前端 5 条 P0(生产开关、刷新令牌、上传路径、WS 地址、富文本上传) | 需重建并部署前端 | 1–2 小时 |
|
||
| P1 | 后端:日志级别、`SCHEDULER_ALLOW_CODE_EXEC=False`、模板 `autoescape=True`、CRUDBase 补权限注入、`get_child_recursion` 防环 | 需完整回归 | 1 天 |
|
||
| P1 | 生产 CORS 显式白名单;`FORWARDED_ALLOW_IPS` 收敛;业务异常返回 4xx | 需回归 | 半天 |
|
||
| P2 | 建立 CI(后端 pytest + ruff、web type-check + vitest);修复测试夹具(fakeredis、assert_route、隔离) | 无运行时影响 | 1–2 天 |
|
||
| P2 | Web i18n 补齐(85/101 页面未接入)与通知枚举修正 | 需产品确认后端是否提供菜单 i18n 字段 | 2–3 天 |
|
||
| P2 | 证书自动续期(当前 2027-03-06 到期,无续期与告警;ACME 路径被 301) | 无运行时影响 | 半天 |
|
||
| P3 | 清理死代码、`any` 治理、键盘可达性(76 处 `@click` 无 tabindex/role) | 渐进 | 持续 |
|
||
|
||
---
|
||
|
||
## 6. 需要决策的事项
|
||
|
||
1. **线上数据库口令与 OPENAI_API_KEY**:两者均出现在 git 历史(旧库口令命中 10 个提交,已推送到 `master/dev`),且镜像内 `.env.prod` 曾包含真实值 → 建议按已泄露处理并重新签发。是否重写 git 历史取决于仓库可见性。
|
||
2. **多租户规格错位**:`REQUIREMENTS.md` v3.6.0 描述 SaaS 多租户(`grep -ril tenant` 在三端 0 命中),实为单租户后台;该文档是 CHANGELOG.md 重命名产生的纯文档提交。是「改文档」还是「立项实现」,只能由产品决定。
|
||
3. **`/user/register` 接口**:无鉴权/无验证码/无开关(当前被 `DEMO_ENABLE` 挡住返回 400)。是否保留该接口、以及演示模式在生产是否应关闭。
|
||
4. **后端是否提供菜单名 i18n 字段**:Web 侧边栏菜单名来自数据库,补齐前端 i18n 后菜单仍为中文。
|
||
5. **移动端范围**:无批量操作、无导入导出是否已在需求中登记为范围外。
|
||
|
||
---
|
||
|
||
## 7. 本次审计对既有结论的修正(避免误判)
|
||
|
||
| 原表述 | 修正 |
|
||
| --- | --- |
|
||
| 任务背景称「Web 端使用 uno.config.ts」 | 不成立:Web 端为 Tailwind v4(`@tailwindcss/vite` + `tailwindcss@^4.3.0`),UnoCSS 仅存在于 app 端;两端令牌体系独立 |
|
||
| 安全报告称 `/user/register` 无鉴权可注册 | 接口确实无鉴权,但当前被演示模式拦截(实测 400);风险取决于 `DEMO_ENABLE` |
|
||
| 队长初判 nginx `ssl_protocols` 缺 TLS1.3 | 误判:`nginx -T` 显示 `ssl_protocols TLSv1.2 TLSv1.3;`,实测 TLS1.3 可用 |
|
||
| 队长初判 `.DS_Store` 清理后仍可访问 | 实为清理成功,后续 200 来自 SPA 回退(`try_files` → index.html),已用 deny 规则彻底封堵 |
|
||
|
||
---
|
||
|
||
## 8. 分报告索引
|
||
|
||
| 报告 | 覆盖 |
|
||
| --- | --- |
|
||
| `backend/audit-backend.md` | 后端架构与代码质量(22 条) |
|
||
| `frontend/audit-frontend.md` | 前端与移动端工程(32 条) |
|
||
| `backend/audit-testing.md` | 测试与可验证性 |
|
||
| `backend/audit-security.md` | 认证/授权/敏感信息(23 条) |
|
||
| `backend/audit-requirements.md` | 需求覆盖对照 |
|
||
| `frontend/audit-uxui.md` | 交互与视觉一致性(590 行) |
|
||
| `docker/audit-deploy.md` | 容器化与部署链路(799 行) |
|
||
|
||
每份报告均包含:问题清单(级别 + 文件:行号 + 现象 + 影响 + 建议)、按优先级的修复清单、未验证/存疑项。
|
||
|
||
---
|
||
|
||
## 9. 修复进展(2026-10-04 更新)
|
||
|
||
### 9.1 已修复并验证(上线)
|
||
|
||
| 类别 | 修复内容 | 验证证据 |
|
||
| --- | --- | --- |
|
||
| 部署面 | MySQL/Redis/backend 端口收敛到 127.0.0.1 | 宿主机 socket 实测:公网监听数 0;服务器自连公网 IP 返回 ConnectionRefused |
|
||
| 部署面 | API 文档仅内网可访问 | 公网 403 / 内网 200 |
|
||
| 部署面 | 容器非 root(uid=1000)运行 | 容器内 `id` = app;日志/上传/迁移目录可写 |
|
||
| 部署面 | TLS 私钥与 .env 权限 600;隐藏文件 403 | stat 与 curl 实测 |
|
||
| 部署面 | 静态资源长缓存;nginx 升级 1.27.5;ACME 校验路径放行 | 响应头 `max-age=31536000, immutable`;证书 153 天 |
|
||
| 部署面 | 每日健康巡检(容器/接口/证书/磁盘/swap) | systemd timer active,日志全绿 |
|
||
| 部署面 | CI(backend: uv+ruff+pytest;frontend: type-check+vitest) | workflow 语法与引用脚本已校验 |
|
||
| 密钥 | SECRET_KEY 与 DATA_ENCRYPTION_KEY 轮换并注入;旧密钥保留于 OLD_KEYS | 旧密钥加密样本仍可解密;用旧公开默认密钥伪造的令牌被拒(InvalidSignatureError) |
|
||
| 口令 | 种子账号 super/admin/user 口令轮换 | 新口令与库内哈希匹配、旧口令 123456 被拒 |
|
||
| 策略 | 生产 CORS 不再回落通配;FORWARDED_ALLOW_IPS 收敛为应用网段;DEMO_ENABLE 显式化 | 恶意 Origin 无许可头;容器内环境变量实测 |
|
||
| 前端 | 5 条 P0(生产构建开关、令牌刷新、上传路径、WS/开发地址) | type-check×2、build:prod、build:h5 均 exit 0;产物第一方 console 0 命中;H5 内 localhost:5180 0 命中 |
|
||
| 后端 | 业务异常按语义返回(客户端 4xx / 内部故障 500);调度器 exec 默认关闭;代码生成按上下文转义 | ruff 全过、pytest 47 passed;AST 级对抗载荷无法注入;线上验证码过期由 500 变 400 |
|
||
| 后端(复核未通过 → t14 pass) | 代码生成注入面曾**未真正关闭**:`column_type` 未安全化即进入代码位置 `mapped_column({{ sqlalchemy_type }})` | 独立复核 t12 = needs_revision;队长复现载荷原样拼入 → t13 整改(类型白名单 + 结构闸门)、t14 复审 **pass** |
|
||
| 后端(错误码收敛) | 单一事实来源:删除全部显式 `status_code`(含构造器参数),HTTP 语义只由 `RET.code → fastapi.status` 映射决定;`_DEFAULT_BUSINESS_STATUS` 降为未登记码安全网 | 业务代码中 `CustomException` 带 status_code = 0;AST 护栏(注入违规即失败);语义探针 400/401/403/404/500 全对 |
|
||
| 后端(异常写法统一) | 删除 `CustomException.internal` 与 132 处包装:意外异常交全局处理器(5xx + 通用文案 + 日志),需运维上下文处 `logger.exception(...) + raise`;6 处 4xx→5xx 反转点补 `except CustomException: raise` | `grep CustomException.internal` = 0;构造器传 status_code 抛 TypeError;AST 审计残留 0;97 用例通过;线上验证码过期仍 400 且保留业务文案 |
|
||
| 后端(全局兜底修正) | SQLAlchemyError 非完整性错误由 400 改为 503(连接)/500(其它),文案不再拼 `exc_type` | 读码 + 探针实测 409/503/503/500/500 |
|
||
| 前端(错误文案) | 错误提示改为「后端 msg 优先 → 业务码兜底 → 状态码兜底 → 通用」,去掉按 code 白名单取文案的耦合(含 Blob 下载分支;401 排除在解析外以不影响静默续期) | type-check exit 0;vitest 41 用例(含 500+4500 与 404+404 文案断言);成功下载分支零改动 |
|
||
| 存储适配器(类型检查暴露的运行时缺陷) | ① OSS:`Client.uploader` 是**方法**,原写法 `self.client.uploader.upload_file(...)` 在绑定方法上取属性 → 运行时 AttributeError(OSS 上传必失败);② SFTP/FTP:`config.host` 可空却直接传给要求 `str` 的 `connect(hostname/host)` | ① 改为 `self.client.uploader().upload_file(...)`(已按 SDK 签名核对 part_size/parallel_num);② 补主机校验,缺主机时返回 400 业务异常;basedpyright 这三个文件由 4 error → 0;运行时探针:缺主机拦截、配主机放行 |
|
||
|
||
### 9.2 仍未处理(需后续立项或产品决策)
|
||
|
||
| 项 | 说明 |
|
||
| --- | --- |
|
||
| 会话失效机制 | 停用/删除/改密/撤权后旧会话仍有效(最长 7 天)——需应用侧改造 |
|
||
| 数据权限注入 | `CRUDBase` 的删除/清空/改状态路径未注入数据权限 |
|
||
| 需求规格错位 | 文档声称的 SaaS 多租户在代码中零实现 |
|
||
| Web i18n 与通知枚举 | 85/101 页面未接入 i18n;通知状态枚举与后端语义冲突 |
|
||
| 测试覆盖与夹具 | 夹具掩盖缺陷、迁移链路 0 覆盖(CI 已建立,但用例仍需补) |
|
||
| 移动端其它缺陷 | `alova.ts:115-119` 请求头写成 `ContentType`(缺连字符),修正需回归上传/表单/JSON 三类请求 |
|
||
| 密钥残留 | 旧数据库口令与 OPENAI_API_KEY 仍存在于 git 历史,建议按已泄露处理并重新签发 |
|
||
| 对外口径(异常统一后的取舍) | 存储「测试连接」密钥环损坏、上传/下载失败、OAuth 渠道未配置、Redis 同步失败等已由具体原因变为通用 5xx 文案(细节只在日志)。若希望管理员在界面看到可操作原因,正确做法是为这些场景定义**业务码**,而不是回到「包装意外异常」 |
|
||
| 仍可能外泄的出口 | `ValueError` 全局处理器返回 `msg=str(exc)`;建议改为通用文案 + 日志,或让我们的校验统一抛 `CustomException` |
|
||
| 静默吞异常(既有) | 48 处「记录日志但不 raise」的 best-effort 处理器(redis_crud、ap_scheduler、middlewares、discover 等),属审计「静默吞异常」主线,建议单独排一轮 |
|
||
|
||
### 9.3 本轮产生的回滚资产
|
||
|
||
- 镜像:`backend:prev`(上一版)、`nginx:rollback-1.25`
|
||
- 配置备份:`docker-compose.yaml.bak-*`、`nginx.conf.bak-*`、`.env.bak-*`
|
||
- 静态成品备份:`backups/{web,app-h5}-<时间戳>`(每类保留 5 份)
|
||
- 种子账号新口令:服务器 `/root/.fa-seed-passwords`(600,登录后请修改并删除)
|