mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +00:00
由 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 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
383 lines
47 KiB
Markdown
383 lines
47 KiB
Markdown
# FastapiAdmin 安全审计报告:认证 / 授权 / 敏感信息
|
||
|
||
- 审计任务:`t4` — 安全审计(认证 / 授权 / 敏感信息)
|
||
- 审计对象:后端(`backend/app`、`backend/main.py`、`backend/env/*`、`backend/sql/*`、`backend/logs/*`)及必要的关联面(`docker/nginx/nginx.conf`、`frontend/web/src/utils/auth/index.ts`、`frontend/web/.env`、git 历史)
|
||
- 工作区:`/Users/tao/workspace/FastapiAdmin`(分支工作树,含未提交改动)
|
||
- 审计时间点:审计时刻工作区实际内容
|
||
- 约束遵守:**只读审计**。未安装依赖、未启动服务、未提交 git、未做任何破坏性或对外发包的利用尝试;仅新增本报告文件。
|
||
- 证据规则:`文件:行号` 为审计时刻实际内容;命令输出为在本地对工作区/仓库的只读操作结果。所有"可利用性"结论都标注了前置条件,未验证的部分进入第 6 节「待验证」。
|
||
- 交叉引用:部署链路的安全问题已有独立报告 [`docker/audit-deploy.md`](../docker/audit-deploy.md)(任务 t6)。本报告只引用其结论,不重复展开。
|
||
|
||
---
|
||
|
||
## 1. 结论速览
|
||
|
||
### 1.1 上线前必须修(blocker / high)
|
||
|
||
| ID | 级别 | 问题 | 关键证据(文件:行号) | 修复建议 |
|
||
|----|------|------|----------------------|---------|
|
||
| **S1** | **blocker** | JWT 签名密钥使用源码内公开默认值,且 `env/.env.dev`、`env/.env.prod`、docker 编排均未注入 `SECRET_KEY` → 任何人可用公开密钥伪造任意用户的访问令牌;同一密钥经 HKDF 派生数据加密主密钥,落库/落缓存的 AI `api_key`、存储源口令可被解密 | `backend/app/config/setting.py:66`(默认值)、`backend/env/.env.prod`(无 `SECRET_KEY` 键)、`grep -n "SECRET_KEY" docker/docker-compose.yaml deploy.sh` 无命中(编排亦未注入)、`backend/app/core/security.py:112-116,139`、`backend/app/utils/crypto_util.py:35-55`;与 `docker/audit-deploy.md` B1 同一根因 | 1) 删除 `SECRET_KEY` 的默认值,改为必填(缺省即启动失败);2) 部署侧注入 ≥32 字节随机值(`openssl rand -hex 32`)并轮换现值;3) 同时显式配置 `DATA_ENCRYPTION_KEY` 并轮换已加密数据 |
|
||
| **S2** | **blocker** | 种子账号 `super` / `admin` / `user` 三者的口令哈希完全相同,实测对应口令 `123456`;该种子在任何环境的**空库启动时自动写入**,`super`/`admin` 还是 `is_superuser=true`;README 直接公开该口令 | `backend/sql/sys_user.json:3-4,23-24,43-44`(同一 pbkdf2 哈希)、`backend/app/scripts/initialize.py:50-58,163-200`(无环境判断)、`README.md:66-67,86`;口令校验命令见 §7.1 | 1) 首次启动随机生成超管口令并一次性输出/落盘(或强制首次登录改密);2) 删除 `sql/sys_user.json` 中的固定哈希,改为按环境注入;3) README 改为"首次初始化口令见控制台" |
|
||
| **S3** | **high** | 停用/删除用户、重置口令、撤销角色后**既有会话不被撤销**:认证只看 Redis 会话里的 `user_status`,每请求查库只校验"用户存在且未删除",不校验 DB 的 `status`;会话滑动续期最长 7 天 | `backend/app/core/dependencies.py:162-182`(用会话里的 `user_status`,DB 查询未含 `status` 条件)、`backend/app/modules/system/user/service.py:145-168`(删除用户不清理会话)、`288-295`(重置口令不清理会话)、`backend/app/modules/system/auth/service.py:531-533`(仅登出自身会话);权限取自会话快照 `dependencies.py:185-189` | 1) `_authenticate` 增加 `user_obj.status != 0 → 401` 并即时清理会话;2) 停用/删除/改密/角色变更后按 `user_id` 扫描并删除 `user_session:{sid}`;3) 会话内权限改为每请求/短 TTL 重算 |
|
||
| **S4** | **high** | 第三方 OAuth 的 `redirect_uri` 完全由请求方指定、无任何域名白名单校验;登录成功后按该地址 302 重定向并把 `access_token`/`refresh_token` 拼进 **URL query** → 钓鱼链接可让受害者把自己的令牌交到攻击者域 | `backend/app/modules/system/auth/oauth_service.py:410-439`(原样保存 `redirect_uri`)、`474-479` + `51-60`(成功重定向携带令牌)、`backend/app/modules/system/auth/controller.py:93-107`;`OAUTH_ALLOWED_HOSTS` 只校验回调域不校验前端回跳域(`setting.py:150`、`oauth_service.py:35-43`) | 1) `redirect_uri` 必须命中配置白名单,否则拒绝(缺失时才回落 `OAUTH_FRONTEND_FALLBACK`);2) 令牌改为短时一次性 code,由前端换 token,禁止出现在 URL;3) 该端点对 `redirect_uri` 做同源/相对路径限制 |
|
||
| **S5** | **high** | `/api/v1/system/user/register` 无鉴权、无验证码、无开关,任何人可无限注册账号;同时登录验证码是"假滑块"(服务端只查 key 状态,无任何人机证据),`/captcha/get` + `/captcha/slider/complete` 可脚本化 → 账号爆破与刷号成本极低 | `backend/app/modules/system/user/controller.py:84-92`(无依赖)、`backend/app/core/dependencies.py:192-234`(授权类与注册无关)、`backend/app/modules/system/auth/service.py:540-549`(作者自述"只能提高滥用成本")、`196-204`、`155-173`(限流仅按 IP,10 次/分钟) | 1) 注册接口加开关(默认关)+ 验证码 + 邮箱/短信校验;2) 换服务端出题的图形/行为验证码;3) 登录限流改为 账号+IP 双维度并收紧窗口 |
|
||
| **S6** | **high** | DEBUG 级别下数据库驱动把**含实参值的完整 SQL** 写入日志,口令哈希明文落盘;`backend/logs/fastapiadmin.log` 中已有 24 行包含种子口令哈希 | `backend/app/core/logger.py:57,71-94`(`aiosqlite` 未列入降噪名单)、实测 `backend/logs/fastapiadmin.log:8407`(见 §7.2);`backend/.gitignore` 未忽略日志本身,但 `backend/logs` 已在根 `.gitignore` 中 | 1) 将 `aiosqlite`/`aioboto3` 等驱动 logger 一并置为 WARNING;2) DEBUG 级别禁止写文件 sink,或对 SQL 值做脱敏;3) 清理现存日志并按需轮换密钥(哈希已在本地明文出现) |
|
||
|
||
### 1.2 可排期修复(medium)
|
||
|
||
| ID | 级别 | 问题 | 关键证据 | 修复建议 |
|
||
|----|------|------|---------|---------|
|
||
| **S7** | medium | 生产环境**无条件暴露** API 文档与 OpenAPI 规范(`register_docs` 无环境判断),且 `/api/v1/openapi.json` 经 nginx `/api/v1` 代理可直接访问 → 完整接口/参数/字段结构外泄 | `backend/app/__init__.py:95-123`、`backend/app/config/setting.py:302-321`(只关 `docs_url`,`openapi_url` 仍默认)、`docker/nginx/nginx.conf:116-135` | 生产环境不注册 `/docs`、`/redoc`,并设置 `openapi_url=None` 或用管理员鉴权包裹 |
|
||
| **S8** | medium | 未鉴权接口暴露系统配置与业务数据:`/api/v1/system/param/info`(全部系统参数,含 IP 黑白名单、演示开关)、`/dict/data/info/{type}`、`/versions/published`、`/monitor/health/check`(版本+环境+DB/Redis 状态)、`/monitor/health/stream` | `backend/app/modules/system/params/controller.py:31-36`、`backend/app/modules/system/params/service.py:101-118`、`backend/app/modules/system/dict/controller.py:180-186`、`backend/app/modules/system/versions/controller.py:36-44`、`backend/app/modules/monitor/health/controller.py:20-45` | 除健康探针外全部补 `get_current_user`;健康接口只返回 `ok/not ok`,版本与环境写入内部监控 |
|
||
| **S9** | medium | CORS 默认"任意来源"且 `allow_credentials=True`(`PROD_CORS_ORIGINS` 为空即回落 `["*"]`),生产亦是 | `backend/app/config/setting.py:57,60,243-248`、`backend/app/core/middlewares.py:22-33`;实测 starlette 0.52.1 行为:简单请求回 `Access-Control-Allow-Origin: *`(`.venv/.../starlette/middleware/cors.py:40-46`) | 生产强制配置显式域名清单并在启动期校验非空(空即启动失败);不使用时关掉 `ALLOW_CREDENTIALS` |
|
||
| **S10** | medium | 代码生成器可执行任意 SQL(`POST /generator/gencode/create` 的 `body.sql` 直送 `execute_sql`);定时任务默认允许执行用户提交代码块(`exec`,等价 RCE) | `backend/app/modules/generator/gencode/controller.py:76-83`、`backend/app/modules/generator/gencode/crud.py:333-342`、`backend/app/config/setting.py:127`、`backend/app/core/ap_scheduler.py:548-563`、`backend/app/modules/task/cronjob/node/service.py:284,371` | 生产 `SCHEDULER_ALLOW_CODE_EXEC=False`(并写入 `.env.example`/部署模板);代码生成器移除"执行任意 SQL"入口,或降级为仅白名单 DDL 模板 |
|
||
| **S11** | medium | 存储源支持 `local` 协议且 `host` 即根目录,配合浏览/下载/删除/重命名接口 → 具备存储权限者可读写服务器任意目录;FTP/SFTP/S3 等协议可作为 SSRF 探测内网 | `backend/app/modules/task/storage/core/local_adapter.py:15-26,43-59`、`backend/app/modules/task/storage/node/controller.py:81-89`(仅看 permission)、`backend/app/modules/task/storage/browse/controller.py:43-75` | local 协议根目录限定在 `static/upload` 且做 `resolve()+is_relative_to` 校验;远端协议加内网网段黑名单与出站策略;该类权限收敛到超管 |
|
||
| **S12** | medium | 缓存监控接口能按 `{cache_name}:{cache_key}` 组合读取**任意 Redis 键**(含 `user_session:*` 会话 JSON:手机号/邮箱/权限);`/cache/clear` 清空整个 DB(会话、调度器 jobstore 一并丢失) | `backend/app/modules/monitor/cache/service.py:34-40,43-57`、`backend/app/modules/monitor/cache/controller.py:37-59`(仅 `module_monitor:cache:*`) | 仅允许白名单前缀(如 `system_config:*`);键名做严格格式校验;`clear` 改为按前缀清理并二次确认 |
|
||
| **S13** | medium | 口令策略被"合法绕过":批量导入用户与导入更新会把口令重置为固定 `123456`(`PASSWORD_IMPORT_DEFAULT`),且无首次登录强制改密;导入仅校验用户名唯一、不校验手机号唯一 | `backend/app/config/setting.py:132-134`、`backend/app/modules/system/user/service.py:370-445`(`425` 固定口令、`434` 更新既有用户)、`backend/app/modules/system/user/model.py:68-71`(`mobile` 非唯一)、`backend/app/modules/system/auth/wx_mini_service.py:302-318`(按手机号登录取 `.first()`) | 导入时随机口令并要求首登改密;`mobile` 加唯一约束(含软删除场景);手机号登录命中多条时报错 |
|
||
| **S14** | medium | 文件上传允许 `.svg`,上传目录 `static/upload` 以 `/static` 同源直出 → 具备上传权限者可构造同源存储型 XSS,窃取 localStorage 中的令牌;声明 MIME 与文件真实内容不一致时只记 warning 不拦截 | `backend/app/config/setting.py:213-222`、`backend/app/__init__.py:89-92`、`backend/app/utils/upload_util.py:215-234`(`return True` 恒定)、前端令牌在 localStorage:`frontend/web/src/utils/auth/index.ts:24-50`。缓解现状:nginx 已全局下发 `X-Content-Type-Options: nosniff` + `Referrer-Policy`(`docker/nginx/nginx.conf:59-63`),可挡住"改扩展名伪装 HTML"的嗅探变体,但**不阻止** `image/svg+xml` 内嵌脚本被执行,且无 CSP;直连后端 8001(绕过 nginx)时全部安全头缺失 | 移除 `.svg`(或强制 `Content-Disposition: attachment` + 独立域);补充 CSP;在应用层也下发 nosniff(覆盖直连场景);MIME 不匹配即拒绝 |
|
||
| **S15** | medium | 工单富文本在**服务端未做任何 HTML 清洗**,仅 Web 端渲染时用 DOMPurify 兜底;公告内容有服务端清洗但工单没有 → 其它客户端(移动端/导出/富文本回显)存在存储型 XSS | `backend/app/modules/system/notice/schema.py:10,30-33`(公告有)、`backend/app/modules/system/ticket/*.py`(grep `sanitize` 无命中)、`frontend/web/src/views/module_system/ticket/index.vue:187,198,240,403,865` | 与公告一致,在 ticket `schema.py` 用 `sanitize_html` 入库清洗;导出/其它渲染端同样清洗 |
|
||
|
||
### 1.3 加固项(low)
|
||
|
||
| ID | 级别 | 问题 | 关键证据 | 修复建议 |
|
||
|----|------|------|---------|---------|
|
||
| **S16** | low | `AuthPermission()` 传空权限列表时等于"仅需登录",`/task/storage/transfer/stream` 即用此写法(当前只推送本人任务,影响有限) | `backend/app/core/dependencies.py:219-220`、`backend/app/modules/task/storage/transfer/controller.py:107` | 空权限列表直接 `raise`,避免"忘记填权限 = 放行" |
|
||
| **S17** | low | `WHITE_API_LIST_PATH` 的注释与命名("无需认证即可访问")与实际用途(仅演示模式写保护放行)不一致;`/user/current/info` 等被列入却仍需认证 → 误导后续维护者 | `backend/app/config/setting.py:188-201`、验证只用在一处:`backend/app/core/middlewares.py:55` | 改名为 `DEMO_WRITE_ALLOWLIST` 并修正注释 |
|
||
| **S18** | low | 无全局鉴权中间件,鉴权完全依赖每个 endpoint 自觉声明依赖 → **默认 fail-open**,本轮即发现 6 个接口漏加(S5/S8) | `backend/app/__init__.py:69-74`(`MIDDLEWARE_LIST` 无鉴权件)、`backend/app/api/v1/routers.py:66-71` | 增加"默认拒绝"的全局依赖/启动期自检:扫描所有路由必须在白名单内或包含 `get_current_user` |
|
||
| **S19** | low | 数据权限(data_scope)实现依赖模型有 `created_id`,无该字段的模型完全不参与过滤(登录/操作日志、字典、参数、定时任务等) | `backend/app/core/permission.py:29-40`、`backend/app/core/base_crud.py:321-325`、`backend/app/modules/system/log/model.py:23,40`、`backend/app/modules/task/cronjob/job/model.py:7` | 明确这些表的可见范围并写进权限矩阵;日志类接口保持管理员专属 |
|
||
| **S20** | low | 前端"锁屏加密密钥"`VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro` 硬编码,且该文件(`frontend/web/.env`)**当前仍被 git 跟踪**(`.gitignore` 后来才加入该路径,已跟踪文件不会被自动忽略) | `frontend/web/.env:25`(`git ls-files --error-unmatch frontend/web/.env` 命中)、`frontend/web/src/layouts/fa-screen-lock/index.vue:168` | 客户端固定密钥无法真正保密,锁屏只作"防窥"用途,不得复用为任何数据加密密钥;如需保留该文件,请 `git rm --cached frontend/web/.env` 并改用 `.example` |
|
||
| **S21** | low | `.env.prod` 中的 `DEMO_ENABLE=True` 是**无效配置**(`Settings` 无该字段且 `extra="ignore"`),演示模式实际由 Redis 系统参数控制 → 运维误以为"演示写保护已开启" | `backend/env/.env.prod:13`、`backend/app/config/setting.py:16-21`(model_config)、`backend/app/core/middlewares.py:48-58`;已被 `docker/audit-deploy.md` 记为 medium | 删除该变量或实现为真实开关,并在文档中说明演示模式配置位置 |
|
||
| **S22** | low | 本地 `env/.env.dev`、`env/.env.prod` 含真实凭据(生产 DB/Redis 口令、可用 LLM API Key),且**历史上曾被提交进 git**(后续 `xxxx` 脱敏并删除文件,但旧提交仍在历史中) | `backend/env/.env.prod`(工作区文件)、`git log -S "FastApi123abc"` 命中 `2ba5b321`、`16054f6c`、`3b45ca2b` 等(见 §7.3) | 视为已泄露:轮换全部相关口令与 API Key;历史清理(`git filter-repo`)或直接废弃该仓库副本 |
|
||
| **S23** | low | 令牌过期语义失效:滑动续期开启时 JWT 的 `exp` **完全不校验**,访问令牌在会话存活期内(最长 7 天)永远有效;且 `_authenticate` 不比对 Redis 中存的活动令牌,无法撤销单个令牌(只能整体删会话) | `backend/app/config/setting.py:71`、`backend/app/core/dependencies.py:105-106`、`backend/app/core/security.py:119-139`、`backend/app/modules/system/auth/service.py:531-533` | 滑动续期改为"服务端 TTL 决定、但 JWT exp 仍校验一个较短上限";`_authenticate` 比对 `access_token:{sid}` 的值,实现单令牌吊销 |
|
||
|
||
---
|
||
|
||
## 2. 详细结论(可利用性判断)
|
||
|
||
### S1 [blocker] JWT 签名密钥为公开默认值(可伪造令牌 + 可解密落库密文)
|
||
|
||
**事实**
|
||
|
||
- `SECRET_KEY` 的声明带默认值,且注释声称"必须通过环境变量设置,无默认值",但代码并未强制:
|
||
`backend/app/config/setting.py:66`
|
||
```python
|
||
SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production"
|
||
```
|
||
- `backend/env/.env.dev`、`backend/env/.env.prod` **都不包含 `SECRET_KEY`**(见 §7.4 的 `grep` 结果),docker 编排同样未注入(`docker/audit-deploy.md` B1)。配置源为 `.env.{ENVIRONMENT}`(`setting.py:16-21,324-329`),因此所有环境实际使用上述公开字符串。
|
||
- 令牌签发/校验都使用该密钥与 `HS256`:`backend/app/core/security.py:112-116`、`139`。`algorithms` 固定为配置算法(无 `alg=none` 风险)。
|
||
- 该密钥同时是 `CryptoUtil` 数据加密主密钥的派生源(`DATA_ENCRYPTION_KEY` 默认 `None`):`backend/app/utils/crypto_util.py:35-55`。
|
||
|
||
**可利用性(分两条链,均有前置条件,已按现实性排序)**
|
||
|
||
1. **链 A(有明确前置,可完整冒充超管)**:`/api/v1/monitor/online/list` 返回全部在线会话的完整 JSON,**包含 `session_id`**(`backend/app/modules/monitor/online/service.py:20-52`;`OnlineOutSchema` 继承 `SessionInfoSchema.session_id`:`backend/app/modules/monitor/online/schema.py:7-8`、`backend/app/core/base_schema.py:78`)。任一持有 `module_monitor:online:query` 的账号即可拿到超管的 `session_id`,随后用公开密钥离线签发
|
||
`{"sub": "<超管 session_id>", "is_refresh": false, "exp": <任意未来时间>}`
|
||
即可通过 `_authenticate`(它只要求 Redis 中存在 `user_session:{sub}`,并信任会话内的 `permissions`/`is_superuser`),无需口令即获得超管权限。
|
||
2. **链 B(无额外权限,危害较小)**:任意已认证用户(含 S5 自助注册的零权限账号)都能签发自己 `session_id` 的令牌并随意设置 `exp`,绕过令牌过期(因 S23 的滑动模式跳过 `exp` 校验)。
|
||
|
||
**为什么不能只依赖 Redis 会话**:`_authenticate`(`backend/app/core/dependencies.py:110-189`)只用 `sub` 去 Redis 取会话,**从不比较请求令牌与 `access_token:{sid}` 中存的值**,因此"签名正确 + 会话存在"即放行;密钥公开就等于"任意会话可被重新签发"。
|
||
|
||
**修复**:见 §1.1 S1;另建议把权限/超管标记从会话快照改为每请求查库,并让 `_authenticate` 比对 Redis 中存的活动令牌(实现真正可撤销)。
|
||
|
||
### S2 [blocker] 种子账号固定弱口令 `123456`(超管可被直接登录)
|
||
|
||
**事实**
|
||
|
||
- 三个种子用户口令哈希完全相同:`backend/sql/sys_user.json:4,24,44` 均为
|
||
`$pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4hAEyCiwsGR48/jgVBjU=`
|
||
其中 `super`、`admin` 为 `is_superuser: true`。
|
||
- 该哈希在本地按实现(PBKDF2-HMAC-SHA256、60 万次迭代、salt 为 `base64.b64decode(salt_b64)`,见 `backend/app/utils/password_util.py:42-59`)复算,命中口令 `123456`(命令与输出见 §7.1)。
|
||
- 种子数据在**任何环境的空库启动时**写入,无环境判断:`backend/app/scripts/initialize.py:50-58`(`init_db` → `__init_data`)、`163-200`(表为空即灌入);`lifespan` 在应用启动时调用(`backend/app/__init__.py:30`)。
|
||
- README 公开该口令:`README.md:66-67,86`(`admin` / `123456`)。
|
||
|
||
**可利用性**:对任何"用本项目默认方式部署、未改口令"的实例,攻击者只需 `POST /api/v1/system/auth/login`(默认 `CAPTCHA_ENABLE=True`,但见 S5:滑块可脚本化)即可获得超管权限。**无需任何前置知识**,是该仓库最高可利用性的问题。
|
||
|
||
**修复**:见 §1.1 S2。注意口令策略(最少 2 类字符,`password_util.py:73-87`)对种子数据无效——`123456` 是纯数字。
|
||
|
||
### S3 [high] 停用/删除/改密/撤权后会话不失效(最长 7 天)
|
||
|
||
**事实**
|
||
|
||
- 认证读取的"用户状态"来自**登录时写入 Redis 的会话快照**,而不是数据库当前值:
|
||
`backend/app/core/dependencies.py:162-166`
|
||
```python
|
||
user_status = user_info.get("user_status", 0)
|
||
...
|
||
if user_status == 1:
|
||
raise CustomException(msg="用户已被停用", ...)
|
||
```
|
||
- 每请求的数据库校验只判断"存在且未软删",**没有 `status` 条件**:`dependencies.py:171-182`。
|
||
- `set_available`(停用/启用)只写库不清理会话:`backend/app/modules/system/user/service.py:269-273`;删除用户(`145-168`)、重置口令(`288-295`)、角色授权变更(`backend/app/modules/system/role/service.py` 的授权接口)同样不清理会话。
|
||
- 会话存活上限:`SESSION_MAX_LIFETIME_SECONDS = 7 天`,且滑动续期在每次请求时刷新 TTL(`setting.py:71-72`、`dependencies.py:124-156`)。
|
||
|
||
**可利用性**:管理员发现异常账号后"停用/改密/撤角色",被处置账号在会话到期前(活跃时被不断续期,最长 7 天)仍可调用全部原权限接口;权限列表也是会话快照(`dependencies.py:185-189`),撤权不生效。属于"处置无效"类风险,需与 S1(会话可被重新签发)一起看。
|
||
|
||
**修复**:见 §1.1 S3。
|
||
|
||
### S4 [high] OAuth `redirect_uri` 无白名单 → 令牌经 302 外泄(未启用第三方登录时为纯开放重定向)
|
||
|
||
**事实**
|
||
|
||
- `/api/v1/system/auth/oauth/{provider}/login?redirect_uri=...`:`redirect_uri` 由请求方任意指定,直接写入 state 缓存,无任何校验:
|
||
`backend/app/modules/system/auth/oauth_service.py:410-439`
|
||
- 回调成功后按该地址重定向,并把令牌拼进 query:
|
||
`oauth_service.py:474-479` + `51-60`
|
||
```python
|
||
def _frontend_success_redirect(frontend_base, access_token, refresh_token, token_type):
|
||
q = urlencode({"access_token": ..., "refresh_token": ..., "token_type": ...})
|
||
return f"{frontend_base}{sep}{q}"
|
||
```
|
||
- `OAUTH_ALLOWED_HOSTS`(默认 `["*"]`,`setting.py:150`)只用于**回调域**校验(`oauth_service.py:35-43`),不约束前端回跳域。
|
||
- 异常分支同样会重定向到调用方给的地址:`oauth_service.py:439`(`_frontend_error_redirect(redirect_uri or fallback, e.msg)`)。
|
||
|
||
**可利用性**
|
||
|
||
- 前置:目标站点启用了 OAuth(`OAUTH_*_CLIENT_ID/SECRET` 非空,默认全空)。攻击者构造
|
||
`https://victim/api/v1/system/auth/oauth/github/login?redirect_uri=https://evil.tld/cb`,诱导受害者点击并完成第三方登录 → 受害者浏览器被重定向到 `https://evil.tld/cb?access_token=...&refresh_token=...`,攻击者拿到可用令牌(凭证交换与用户绑定流程本身是正确的:state 一次性消费、provider 比对,见 `oauth_service.py:331-345`)。
|
||
- 前置不满足时(默认未配置渠道密钥):仍是**开放重定向**(302 到任意域并带 `oauth_error`),可用于钓鱼跳板。
|
||
- 附带问题:令牌出现在 URL query,会进入浏览器历史、Referer 与网关访问日志。
|
||
|
||
**修复**:见 §1.1 S4。
|
||
|
||
### S5 [high] 开放注册 + 验证码无实质人机校验
|
||
|
||
**事实**
|
||
|
||
- `POST /api/v1/system/user/register` 无鉴权依赖、无验证码、无配置开关:`backend/app/modules/system/user/controller.py:84-92`。注册会创建 `status=0`、无角色用户(`backend/app/modules/system/user/service.py:308-327`),因此不构成直接的权限提升,但可用于刷号、占用用户名(含 `201-...` 类业务标识)、以及拿到一个"合法登录态"配合 S1/S16。
|
||
- 验证码:`CaptchaService` 自述"滑块是纯前端交互,服务端拿不到任何可信的人机证据(拖动轨迹可以伪造)",实现只需 `captcha_key` + 指纹(IP+UA)+ ≥0.2s 间隔即可通过:`backend/app/modules/system/auth/service.py:540-549,610-648`。
|
||
- 登录限流按 IP 固定窗口 10 次/分钟,且对 `127.0.0.1`/`unknown` 直接放行:`auth/service.py:155-173`、`setting.py:73-74`。IP 来源依赖 `X-Real-IP`/`X-Forwarded-For` 且只在"直连对端是可信代理"时信任(`backend/app/utils/ip_local_util.py:24-60`)——但部署侧存在 `FORWARDED_ALLOW_IPS=*` + 8001 直连(`docker/audit-deploy.md` H2),一旦成立即可伪造 IP 绕过限流(见 §6 待验证)。
|
||
- `POST /api/v1/system/user/password/forget` 也未鉴权,但实现是**安全的**:不返回账号是否存在、不改密(`controller.py:74-81`、`service.py:297-306`),此项不作为问题。
|
||
|
||
**可利用性**:对已知用户名做口令爆破,自动化成本被限流(10 次/分钟/IP)压低但不为 0(约 14400 次/天/IP,配合代理池或伪造头可放大);配合 S2 的弱口令几乎无意义——弱口令本身已被直接命中。
|
||
|
||
**修复**:见 §1.1 S5。
|
||
|
||
### S6 [high] 敏感数据(含口令哈希)写入日志文件
|
||
|
||
**事实**
|
||
|
||
- `setup_logger` 把根 logger 与所有既有 logger 接到 `InterceptHandler`,并把 `LOGGER_LEVEL`(dev 默认 `DEBUG`)作为级别;降噪白名单**不含 `aiosqlite`**:`backend/app/core/logger.py:49-94`(`aiomysql` 在列,`aiosqlite` 不在)。
|
||
- 实测日志中 24 行包含种子口令哈希明文,例如 `backend/logs/fastapiadmin.log:8407`:
|
||
```
|
||
2026-10-04 03:57:52.396 | DEBUG | aiosqlite.core:_connection_worker_thread:62 - executing functools.partial(..., 'INSERT INTO sys_user (username, password, name, ...' ... pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4h
|
||
```
|
||
(完整命令与输出见 §7.2)
|
||
- 生产 `LOGGER_LEVEL=INFO`(`backend/env/.env.prod`)时不触发;但 DEBUG 在 dev/调试环境是默认值,日志文件按天轮转保留 30 天(`logger.py:65-66`)。
|
||
|
||
**可利用性**:任何能读到 `backend/logs/*.log`(开发机共享、容器挂载、日志采集平台、备份)的人,直接获得口令哈希(PBKDF2 600k,离线爆破成本较高,但 S2 的种子哈希等于明文口令)以及 SQL 中的其它业务数据。
|
||
|
||
**修复**:见 §1.1 S6。
|
||
|
||
### S7 [medium] 生产无条件暴露 `/docs`、`/redoc`、`/openapi.json`
|
||
|
||
- `FASTAPI_CONFIG` 只把 `docs_url`/`redoc_url` 设为 `None`(`setting.py:308-310`),但 `register_docs` 又**无条件**注册 `/docs`、`/redoc`(`app/__init__.py:95-123`),无 `ENVIRONMENT` 判断;`openapi_url` 保持默认(`/openapi.json`)。
|
||
- `ROOT_PATH="/api/v1"` 经 Starlette 的 `get_route_path` 生效(`.venv/.../starlette/_utils.py:90-102`、`fastapi/applications.py:1160-1163`),故实际外部路径为 `/api/v1/docs`、`/api/v1/redoc`、`/api/v1/openapi.json`,全部落在 nginx 的 `location /api/v1` 代理范围内(`docker/nginx/nginx.conf:116-135`)。
|
||
- 可利用性:无需认证即可下载完整 API 结构(接口清单、请求/响应 schema、字段名),显著降低后续攻击成本。修复见速览表 S7。
|
||
|
||
### S8 [medium] 未鉴权接口暴露系统配置与业务数据
|
||
|
||
已确认"无任何鉴权依赖"的路由(解析全部 `controller.py` 的 decorator→签名块,见 §7.5):
|
||
|
||
| 路由 | 说明 | 证据 |
|
||
|------|------|------|
|
||
| `GET /api/v1/system/param/info` | 返回 Redis 中全部系统参数(`sys_name`、`demo_enable`、`ip_white_list`、`ip_black_list`、`ip_location_enable` …) | `params/controller.py:31-36`、`params/service.py:101-118`、种子 `backend/sql/sys_param.json` |
|
||
| `GET /api/v1/system/dict/data/info/{dict_type}` | 任意字典类型的数据 | `dict/controller.py:180-186` |
|
||
| `GET /api/v1/system/versions/published` | 已发布版本列表(`auth = AuthSchema()` 空上下文) | `versions/controller.py:36-44` |
|
||
| `GET /api/v1/monitor/health/check`、`/health/stream` | 应用名/版本/环境 + DB/Redis 连通状态 | `monitor/health/controller.py:20-45`、`health/service.py:42-54` |
|
||
| `POST /api/v1/system/user/register`、`/password/forget` | 见 S5(`forget` 实现安全,仅列举) | `user/controller.py:74-92` |
|
||
|
||
说明:`setting.py:188-201` 把其中若干路径列进 `WHITE_API_LIST_PATH` 并注释为"无需认证即可访问",但该列表**仅**用于演示模式写保护判断(`middlewares.py:55`,全仓唯一引用)。因此这些接口的公开性是"漏加依赖"而非"有意白名单",详见 S17/S18。
|
||
|
||
### S9 [medium] CORS 任意来源 + 允许携带凭证
|
||
|
||
- `ALLOW_ORIGINS` 在非 PROD 或 `PROD_CORS_ORIGINS` 为空时返回 `["*"]`(`setting.py:243-248`),`ALLOW_CREDENTIALS=True`(`setting.py:60`)。
|
||
- 实测安装版本 starlette 0.52.1 的行为:`allow_all_origins=True` 时简单响应返回 `Access-Control-Allow-Origin: *` 且同时带 `Access-Control-Allow-Credentials: true`(`.venv/lib/python3.12/site-packages/starlette/middleware/cors.py:38-47`);预检因 `allow_credentials` 会回显 Origin。
|
||
- 可利用性评估(不夸大):本项目认证走 `Authorization` 头而非 Cookie(全仓 `grep set_cookie` 无命中),浏览器不会自动附带凭证,因此**当前不构成"任意站点读取已登录用户数据"**;但任意站点可读取所有未鉴权接口(S8)的响应,且一旦将来引入 Cookie 会话即变为严重问题。修复见速览表 S9。
|
||
|
||
### S10 [medium] 任意 SQL 执行与用户代码执行(默认开启)
|
||
|
||
- `POST /api/v1/generator/gencode/create` 把请求体 `sql` 直接送 `execute_sql(text(sql))`:`generator/gencode/controller.py:76-83`、`generator/gencode/crud.py:333-342`。权限 `module_generator:gencode:create`。
|
||
- 定时任务节点保存 `code_block` 并在调度器中 `exec(code_block, module.__dict__)`,仅由 `SCHEDULER_ALLOW_CODE_EXEC` 控制,**默认 True**(`setting.py:127`),env/部署模板均未覆盖(`backend/env/.env.prod` 无该键);相关校验见 `ap_scheduler.py:548-563`、`task/cronjob/node/service.py:284,371`。权限 `module_task:cronjob:job:task`。
|
||
- 可利用性:拥有上述权限的账号可执行任意系统命令/任意 SQL(MySQL 下还可 `INTO OUTFILE` 写文件),属"设计内的高危能力"。若把这些权限授给非超管角色,实际后果等同服务器失陷。修复见速览表 S10。
|
||
|
||
### S11 [medium] 存储源 `local` 协议可读写服务器任意目录 / 远端协议 SSRF
|
||
|
||
- `LocalStorageAdapter.__init__` 把 `host` 当根目录,`_abs_path` 仅拒绝 `..`(`local_adapter.py:15-26`),因此 `host="/"` 或 `/etc` 即可浏览、下载、删除、重命名宿主机文件(`_sync_list/_sync_download/_sync_delete/_sync_rename`,`43-120`);路径以 `/` 开头会被 `lstrip("/")` 归到 root 下,但 root 本身可被配置为任意目录。
|
||
- 节点创建/更新只需 `module_task:storage:node:create/update`(`storage/node/controller.py:81-101`);浏览接口 `storage/browse/controller.py:28-135` 用同一套配置。
|
||
- FTP/SFTP/S3/OSS/COS/OBS 适配器接受用户填写的 `host:port`(`storage/node/model.py:14-16`),具备内网探测(SSRF)能力。
|
||
- 缓解:存储源口令以 Fernet 加密存储且不回显(`storage/node/schema.py:138-149`),但该密钥派生自 S1 的公开 `SECRET_KEY` → 加密对读代码者无效。
|
||
- 修复见速览表 S11。
|
||
|
||
### S12 [medium] 缓存监控可读任意 Redis 键 / 一键清库
|
||
|
||
- `get_monitor_cache_value` 拼接 `f"{cache_name}:{cache_key}"` 并直接 `get`(`monitor/cache/service.py:34-40`);`cache_name` 填 `user_session`、`cache_key` 填某 `session_id` 即可读出该用户会话 JSON(手机号/邮箱/权限/IP);也可读 `ai_model_config:items:{uid}`(`api_key` 密文,但 S1 使其可解)。
|
||
- `clear_monitor_cache_all` 清空整个 DB(`service.py:52-57`),会一并删除全部会话与调度器 jobstore,等于强制全员下线 + 任务状态丢失。
|
||
- 权限仅 `module_monitor:cache:query/delete`(`monitor/cache/controller.py:37-61`)。
|
||
- 修复见速览表 S12。
|
||
|
||
### S13 [medium] 固定默认口令的导入路径 + 手机号登录的账号匹配风险
|
||
|
||
- 批量导入:新用户口令固定为 `PASSWORD_IMPORT_DEFAULT="123456"`(`setting.py:134`、`user/service.py:425`);`update_support=True` 时对已存在用户执行 `UserUpdateSchema(**user_data)` 更新,**同样把口令重置为 123456**(`user/service.py:428-437`)。权限 `module_system:user:import`。
|
||
- 无"首次登录必须改密"机制(全仓无 `must_change_password` 类字段)。
|
||
- `mobile` 仅有普通索引、非唯一(`user/model.py:71`),而导入路径不校验手机号唯一(只校验用户名,`user/service.py:428`)→ 可能出现两账号同手机号,微信手机号登录取 `.first()`(`wx_mini_service.py:302-318`),存在账号串号隐患。
|
||
- 修复见速览表 S13。
|
||
|
||
### S14 [medium] 上传 SVG + 同源静态直出 → 存储型 XSS 面
|
||
|
||
- 允许扩展名含 `.svg`(`setting.py:213-222`);上传校验只对"危险扩展名"和允许清单做判断,内容类型不符仅 `logger.warning` 后 `return True`(`upload_util.py:215-234`);文件路径穿越有校验(`upload_util.py:410-411,437-456`),下载有 `is_relative_to` 校验(`common/file/service.py:46-58`)——**这两处实现是好的**。
|
||
- 上传目录 `static/upload` 通过 `app.mount(settings.STATIC_URL, StaticFiles(...))` 同源直出(`app/__init__.py:89-92`)。
|
||
- 浏览器直接打开 `/static/upload/.../*.svg` 会以 `image/svg+xml` 渲染并执行内嵌脚本。缓解现状:nginx 在 http 级下发 `X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`(`docker/nginx/nginx.conf:59-63`),可挡住"改成 `.png` 伪装 HTML 被嗅探"的变体,但 `image/svg+xml` 是声明类型本身,nosniff 不阻止其脚本执行;仓库内 `grep Content-Security-Policy` 无命中,且应用层(直连 8001 或开发环境)没有任何安全响应头(`grep -rn "X-Content-Type-Options" backend/app` 无命中)。前端令牌存 localStorage(`frontend/web/src/utils/auth/index.ts:24-50`),一旦同源 XSS 成立即可窃取令牌(影响 S1 链 B 的令牌豁免)。
|
||
- 前置:攻击者需具备 `module_common:file:upload` 权限并诱导受害者打开链接。
|
||
- 修复见速览表 S14。
|
||
|
||
### S15 [medium] 工单富文本服务端未清洗
|
||
|
||
- 公告在入库前清洗(`notice/schema.py:10,30-33` 调 `sanitize_html`,白名单见 `utils/xss_util.py:99-118`)。
|
||
- 工单模块全目录 `grep sanitize` 无命中(`app/modules/system/ticket/`),仅前端渲染时用 DOMPurify(`frontend/web/src/views/module_system/ticket/index.vue:403,865`)。服务端不设防意味着任何非 Web 渲染方(移动端、导出、日志回显、第三方集成)会直接执行内容中的脚本。
|
||
- 修复见速览表 S15。
|
||
|
||
### S16–S23(low)
|
||
|
||
见 §1.3 速览表;其中 S18(默认 fail-open 的鉴权模型)是 S5/S8 的根因,建议优先以"启动期路由自检"的方式固化,避免后续新增接口再次漏加。
|
||
|
||
补充说明 S23(令牌过期语义):认证时按 `decode_access_token(token, verify_exp=not settings.TOKEN_SLIDING_EXPIRE)` 调用(`dependencies.py:105-106`),默认 `TOKEN_SLIDING_EXPIRE=True`(`setting.py:71`)→ PyJWT 跳过 `exp` 校验,访问令牌只要会话仍在 Redis 中就有效;而会话在每次请求时被续期(`dependencies.py:124-156`),仅由 `SESSION_MAX_LIFETIME_SECONDS`(7 天)封顶。因此"12 小时的 access_token 有效期"这一配置(`ACCESS_TOKEN_EXPIRE_SECONDS`)在默认配置下**不产生实际约束**,且无法单独吊销某个已签发令牌(`logout` 直接删除整个会话,`auth/service.py:531-533`)。<br>可利用性:拿到任一份令牌(日志、代理缓存、浏览器历史、S4 的 URL 泄漏)后,其可用窗口从"12 小时"变为"受害者会话剩余寿命",并可直接用公开密钥重签(S1)延长至任意时长。
|
||
|
||
---
|
||
|
||
## 3. 已核查、未发现问题的项(避免误判)
|
||
|
||
| 项 | 结论 | 证据 |
|
||
|----|------|------|
|
||
| SQL 注入(业务查询) | 未发现。查询条件全部经 SQLAlchemy 表达式构造;`search` 键来自 Pydantic schema 声明(`json_schema_extra={"q": ...}`),`order_by` 走 `getattr(model, field)` 属性访问 | `backend/app/core/base_crud.py:303-387`、`backend/app/core/base_schema.py:136-162` |
|
||
| 代码生成器列表查询的 f-string SQL | **无注入**:`where_sql` 只拼接固定片段,变量一律用绑定参数(`:name_kw` 等) | `backend/app/modules/generator/gencode/crud.py:176-226` |
|
||
| JWT 算法混淆(`alg=none`、RS/HS 混淆) | 未发现:`decode` 固定白名单算法为配置值 `HS256` | `backend/app/core/security.py:139` |
|
||
| 刷新令牌重放 | 有防护:refresh token 轮换 + 不一致即撤销整个会话 | `backend/app/modules/system/auth/service.py:449-463` |
|
||
| 登出 | 会话/双令牌均被删除(`logout` 需先通过认证) | `auth/service.py:522-537`、`auth/controller.py:82-90` |
|
||
| 越权混用"用户端/管理端令牌" | 未发现双端令牌体系;权限完全由 RBAC 会话决定,令牌本身不含角色信息 | `backend/app/core/base_schema.py:99-121`、`dependencies.py:185-189` |
|
||
| 垂直越权(普通用户访问管理接口) | 除 S8 所列公开接口与 S16 外,所有管理接口均带 `AuthPermission([...])`;`AuthPermission` 对无权限用户返回 403,`is_superuser` 才短路放行 | `backend/app/core/dependencies.py:206-234` |
|
||
| `AuthPermission` 通配符 | `*` / `*:*:*` 才短路,未发现空串短路 | `dependencies.py:222-223` |
|
||
| 水平越权(数据权限) | 已在 `base_crud._build_conditions` 统一注入 `Permission` 过滤,且 `created_id` 为空集合时降级为 `created_id == self.id` | `base_crud.py:321-325`、`permission.py:29-80` |
|
||
| 模板注入(Jinja2 SSTI) | 未发现:Jinja 模板来自仓库内固定文件,渲染上下文是 DB 元数据(值不参与模板语法) | `backend/app/modules/generator/gencode/jinja2_template_util.py:150-264` |
|
||
| 反序列化 | 仅 `pickle.loads` 用于读取 APScheduler 自身 jobstore 状态字节,非外部输入解码路径(不排除"能写 Redis 者"这一常规信任边界) | `backend/app/core/ap_scheduler.py:578,591` |
|
||
| 上传路径穿越 / 任意文件下载 | 有防护:文件名清洗 + `resolve().is_relative_to(UPLOAD_FILE_PATH)` 双重校验;下载同样校验 | `backend/app/utils/upload_util.py:107-124,410-456`、`backend/app/modules/common/file/service.py:46-58` |
|
||
| 操作日志敏感字段 | 有脱敏:请求体/响应体按敏感键名递归打码(含 `access_token`/`refresh_token`/`password`/`api_key` 等),文件对象不入库 | `backend/app/core/router_class.py:15-41,86-116` |
|
||
| 异常响应信息泄漏 | 生产环境不外泄 `data`/DB 细节(`ENVIRONMENT == PROD` 判断) | `backend/app/core/exceptions.py:72-74,115-130` |
|
||
| 存储源口令回显 | 不回显(`password` 字段 `exclude=True`,只给 `has_password`) | `backend/app/modules/task/storage/node/schema.py:138-149` |
|
||
| 用户列表/详情泄漏口令哈希 | 输出模型不含 `password` | `backend/app/modules/system/user/schema.py:218-237` |
|
||
| 注册/自助改资料的字段越权(mass assignment) | 未发现:`UserRegisterSchema` / `CurrentUserUpdateSchema` 均不含 `is_superuser`/`role_ids`/`status`,Pydantic 默认忽略多余字段 | `user/schema.py:25-33,159-183`、`user/controller.py:42-49,84-92` |
|
||
| 忘记密码接口 | 安全:不区分账号是否存在、不直接改密 | `user/controller.py:74-81`、`user/service.py:297-306` |
|
||
| 客户端 IP 伪造 | 实现是收敛的(仅在直连对端为可信代理时采信 `X-Real-IP`/右起取 XFF);风险在部署侧(见 §6) | `backend/app/utils/ip_local_util.py:24-60` |
|
||
| 本地日志中的令牌 | 未发现 `Bearer`/JWT 落日志(`grep -c "Bearer " logs/*.log` = 0);但存在 S6 的口令哈希问题 | §7.2 |
|
||
| 接口文档/静态资源鉴权 | 属于设计取舍(S7)而非"漏鉴权" | `app/__init__.py:95-123` |
|
||
| 安全响应头(nginx 层) | 已下发 `X-Frame-Options: SAMEORIGIN`、`X-Content-Type-Options: nosniff`、`X-XSS-Protection`、`Referrer-Policy: strict-origin-when-cross-origin`;缺 HSTS/CSP(与 t6 的 M7 一致),应用层直连时无任何安全头 | `docker/nginx/nginx.conf:59-63`;`grep -rn "X-Content-Type-Options" backend/app` 无命中 |
|
||
|
||
---
|
||
|
||
## 4. 最小修复路径(按投入产出排序)
|
||
|
||
**P0(当天可完成,阻断最高可利用性风险)**
|
||
|
||
1. `setting.py:66` 去掉 `SECRET_KEY` 默认值 → 改为必填并在启动期校验长度 ≥32;`env/.env.example` 增加 `SECRET_KEY=` 必填说明;现网轮换密钥并重新登录全体用户。
|
||
2. 处理种子口令:删除 `sql/sys_user.json` 的固定哈希或改为启动时随机生成 + 一次性输出;README 去除明文口令(S2)。
|
||
3. 部署侧落实 `docker/audit-deploy.md` 的 B1/B3(不要在镜像层烤入 `.env.prod`)与 P0 同步执行。
|
||
4. 关闭可达的公网调试面:生产不注册 `/docs`/`/redoc`,`openapi_url=None`(S7);`SCHEDULER_ALLOW_CODE_EXEC=False`(S10)。
|
||
|
||
**P1(1–2 天,收敛授权与会话语义)**
|
||
|
||
5. `_authenticate` 增加 DB `status` 校验 + 会话比对(S3),并提供"按 user_id 清理会话"的服务方法,在停用/删除/改密/改角色处调用。
|
||
6. S8 的 6 个公开接口按需补 `get_current_user`;`AuthPermission` 空列表直接拒绝(S16)。
|
||
7. 新增启动期路由自检:遍历 `app.routes`,凡未含 `get_current_user`/白名单声明的路由打 warning 或直接失败(S18)。
|
||
8. OAuth `redirect_uri` 白名单(S4),令牌改为一次性 code 换取(参考 S4 修复项 2)。
|
||
|
||
**P2(排期,纵深防御)**
|
||
|
||
9. 口令与会话策略:注册开关默认关 + 真实验证码(S5);导入随机口令 + 首登改密 + `mobile` 唯一约束(S13)。
|
||
10. 敏感面收敛:缓存监控键名白名单(S12)、存储源 local 根目录与远端协议出站限制(S11)、上传去掉 `.svg` 并加 `nosniff`/CSP(S14)、工单入库清洗(S15)。
|
||
11. 日志与配置卫生:`aiosqlite` 降噪、DEBUG 不落盘(S6);强制生产 CORS 显式域名(S9);清理无效的 `DEMO_ENABLE`、重命名 `WHITE_API_LIST_PATH`(S21/S17);轮换所有历史泄露凭据(S22)。
|
||
|
||
---
|
||
|
||
## 5. 结论一致性说明
|
||
|
||
- 本报告与 `docker/audit-deploy.md` 的 S1/B1、S10/H2、S21(其 medium 列表中的 `DEMO_ENABLE`)指向同一批根因,视角不同(应用代码 vs 部署链路),修复时需要同时落地,避免"只改代码没改编排"(例如 `SECRET_KEY` 必须由编排注入)。
|
||
- 本报告未包含对前端 Web 应用与 uniapp 移动端的完整审计(前端仅核查了令牌存储与锁屏密钥两处与认证/敏感信息直接相关的点,S14/S20)。若需要覆盖 XSS/CSRF/依赖漏洞等前端面,应由前端审计任务单独产出。
|
||
|
||
---
|
||
|
||
## 6. 待验证清单(附验证方法)
|
||
|
||
| 编号 | 待验证问题 | 验证方法(只读、不改代码、不对外发包) |
|
||
|------|-----------|----------------------------------------|
|
||
| V1 | `X-Forwarded-For` 伪造是否真能绕过登录限流(依赖部署编排 `FORWARDED_ALLOW_IPS=*`、后端 8001 是否对外) | 在测试环境按 `docker/docker-compose.yaml` 启动后,用 `curl -H 'X-Forwarded-For: 1.2.3.4'` 连续请求 `/api/v1/system/auth/login`,观察是否出现"登录尝试过于频繁";同时确认 8001 未被 nginx 屏蔽(t6 H2 已给静态证据,此条为动态确认) |
|
||
| V2 | 默认部署下 `GET /api/v1/param/info`、`/api/v1/openapi.json`、`/api/v1/docs` 是否真能匿名访问 | 启动后 `curl -i http://127.0.0.1:8001/api/v1/openapi.json`(不带 Authorization);再经 nginx `curl -i https://<host>/api/v1/openapi.json` |
|
||
| V3 | OAuth 重定向链在"已配置渠道密钥"时的实际行为(本地默认未配置渠道密钥,无法端到端复现) | 在测试环境填入测试用 GitHub OAuth App(`OAUTH_GITHUB_CLIENT_ID/SECRET`),用 `redirect_uri=https://example.com/cb` 走完授权,检查 302 Location 是否携带 `access_token` |
|
||
| V4 | 停用用户后旧会话是否仍可用(S3 的动态确认) | 用 A 账号登录取 token → 管理员停用 A → 用同一 token 请求 `/api/v1/system/user/current/info`,观察是否仍 200 |
|
||
| V5 | `mobile` 是否存在重复数据(影响手机号登录串号) | 只读 SQL:`SELECT mobile, COUNT(*) FROM sys_user WHERE mobile IS NOT NULL GROUP BY mobile HAVING COUNT(*)>1;` |
|
||
| V6 | `backend/env/.env.dev|.env.prod` 中的真实凭据是否曾被推送到远端(本地 git 历史命中 `FastApi123abc`,但未确认远端是否可达) | `git log --all -S "FastApi123abc" --oneline`(已确认本地历史命中)、`git remote -v` 与远端仓库可见性核查;无论结果如何,按 S22 直接轮换 |
|
||
| V7 | 生产环境 `PROD_CORS_ORIGINS` 是否实际配置(代码默认回落到 `["*"]`) | 在目标环境执行 `python -c` 加载 `settings` 打印 `ALLOW_ORIGINS`;或查看部署用的 `.env`(`docker/.env` 未入库,需在服务器上核对) |
|
||
| V8 | 定时任务 `exec` 能力在生产是否真的开放 | 在目标环境读取 `SCHEDULER_ALLOW_CODE_EXEC` 生效值(`python -c "from app.config.setting import settings; print(settings.SCHEDULER_ALLOW_CODE_EXEC)"`) |
|
||
| V9 | 缓存监控接口能否读到他人会话(需一个具备 `module_monitor:cache:query` 的测试账号) | 测试环境:先 `GET /api/v1/monitor/online/list` 取 `session_id`,再 `GET /api/v1/monitor/cache/get/value/user_session/<sid>`,观察是否返回会话 JSON |
|
||
|
||
---
|
||
|
||
## 7. 附录:关键命令与输出
|
||
|
||
### 7.1 种子口令哈希 → 明文 `123456`(只读本地复算)
|
||
|
||
```
|
||
$ python3 - <<'EOF'
|
||
import hashlib, base64, hmac
|
||
salt = base64.b64decode("XX20aO1v73xS0JnoewXNtw==")
|
||
expected = base64.b64decode("PEaVHV1N5L7PfYQw2lCAQOc4hAEyCiwsGR48/jgVBjU=")
|
||
for c in ["123456","admin123","Admin123", ...]:
|
||
if hmac.compare_digest(hashlib.pbkdf2_hmac('sha256', c.encode(), salt, 600000), expected):
|
||
print("MATCH:", c)
|
||
EOF
|
||
MATCH: 123456
|
||
```
|
||
|
||
迭代次数/编码方式与实现一致:`backend/app/utils/password_util.py:12-14,25-29,42-59`。
|
||
|
||
### 7.2 日志中的口令哈希(本机只读 grep)
|
||
|
||
```
|
||
$ grep -rc "pbkdf2-sha256" backend/logs/fastapiadmin.log
|
||
24
|
||
$ sed -n '8407p' backend/logs/fastapiadmin.log | grep -o "pbkdf2-sha256\$[0-9]*\$[^']*"
|
||
pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4h
|
||
$ grep -rc "Bearer " backend/logs/*.log
|
||
backend/logs/fastapiadmin.log:0 # 未发现令牌落日志
|
||
```
|
||
|
||
### 7.3 git 历史中的真实凭据(只读)
|
||
|
||
```
|
||
$ git log --all --oneline -S "FastApi123abc"
|
||
2ba5b321 fix: 统一数据库和Redis的密码为小写格式
|
||
37d4dade feat(nginx): 更新SSL证书路径配置
|
||
c178b996 chore: 删除前后端环境配置文件
|
||
...
|
||
$ git show 2ba5b321 | grep -n "PASSWORD"
|
||
- MYSQL_ROOT_PASSWORD: "FastApi123abc"
|
||
- MYSQL_PASSWORD: "FastApi123abc"
|
||
- DATABASE_PASSWORD: "FastApi123abc"
|
||
- REDIS_PASSWORD: "FastApi123abc"
|
||
```
|
||
|
||
`git ls-files` 显示:`backend/env/.env.dev`、`backend/env/.env.prod` 已从索引移除(当前只保留 `.env.example`),但历史提交仍可检出(S22);另需注意 `frontend/web/.env` **仍在跟踪中**(S20),即便 `.gitignore` 已列入该路径。
|
||
|
||
### 7.4 环境配置文件未注入 `SECRET_KEY`
|
||
|
||
```
|
||
$ grep -n "SECRET_KEY" backend/env/.env.dev backend/env/.env.prod backend/env/.env.example
|
||
(backend/env/.env.dev、.env.prod 无输出;.env.example 的注释里提到需修改 SECRET_KEY,但无该行)
|
||
```
|
||
`Settings.model_config.env_file = ENV_DIR / f".env.{ENVIRONMENT}"`(`backend/app/config/setting.py:16-21`)→ 因此实际生效值就是 `setting.py:66` 的默认字符串。
|
||
|
||
### 7.5 未鉴权路由的提取方式(只读静态解析)
|
||
|
||
对 `backend/app/**/controller.py` 的每个 `@XRouter.get|post|put|delete|patch` 装饰器块(装饰器 + 函数签名)做文本扫描,凡块内不含 `AuthPermission` 与 `get_current_user` 的即判为"无鉴权依赖",得到 §1.1 S5 / §3 S8 的清单;同时用 `grep -rn "Security(AuthPermission" ` 交叉核对(189 处命中,覆盖 24 个 controller)。该方法只做静态判断,不含运行时行为(动态确认见 §6 V2)。
|
||
|
||
---
|
||
|
||
*报告结束。本报告为只读审计产出,未修改任何业务代码;仓库唯一新增文件即本报告。*
|