chore(deploy): 健康检查发正确 Host、list 型配置改 JSON、Host 校验收窄

- backend 健康检查改为发送正确的 Host 头(而非放宽 ALLOWED_HOSTS);
  配合 ALLOW_LOCALHOST_HOSTS=false,Host: localhost 现在返回 400,容器自检仍 exit 0
- OAUTH_ALLOWED_HOSTS / CORS_STRICT_STARTUP / SCHEDULER_ALLOW_CODE_EXEC 接入 compose 注入
- ⚠️ OAUTH_ALLOWED_HOSTS 是 list[str],必须写 JSON 数组:本次部署曾因写成逗号分隔字符串
  导致启动期 SettingsError、容器重启、网关 502(已修复,并在 compose 默认值中固化 JSON 形态)
- DEPLOYMENT.md 新增「配置取值格式」章节记录该坑与排查方法
- AUDIT-REPORT.md 更新修复进展(含复核 t12 驳回与 t13/t14 通过、残余项)

线上验证:四端 200;Host=localhost → 400;健康检查 exit 0;公网 docs/.DS_Store → 403
This commit is contained in:
zhangtao
2026-10-04 05:12:37 +08:00
parent 07fa789d2e
commit c73daa7eae
4 changed files with 85 additions and 2 deletions
+41
View File
@@ -150,3 +150,44 @@
| `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 |
| 后端(复核未通过,整改中) | 代码生成注入面**未真正关闭**:`column_type` 未安全化即进入代码位置 `mapped_column({{ sqlalchemy_type }})` | 独立复核 t12 = needs_revision;队长复现 `varchar(100)); import app.config.setting as _s; ... #` 原样拼入 → 自动跟进 t13(修复)/t14(复审) |
### 9.2 仍未处理(需后续立项或产品决策)
| 项 | 说明 |
| --- | --- |
| 会话失效机制 | 停用/删除/改密/撤权后旧会话仍有效(最长 7 天)——需应用侧改造 |
| 数据权限注入 | `CRUDBase` 的删除/清空/改状态路径未注入数据权限 |
| 需求规格错位 | 文档声称的 SaaS 多租户在代码中零实现 |
| Web i18n 与通知枚举 | 85/101 页面未接入 i18n;通知状态枚举与后端语义冲突 |
| 测试覆盖与夹具 | 夹具掩盖缺陷、迁移链路 0 覆盖(CI 已建立,但用例仍需补) |
| 移动端其它缺陷 | `alova.ts:115-119` 请求头写成 `ContentType`(缺连字符),修正需回归上传/表单/JSON 三类请求 |
| 密钥残留 | 旧数据库口令与 OPENAI_API_KEY 仍存在于 git 历史,建议按已泄露处理并重新签发 |
### 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,登录后请修改并删除)
+16 -1
View File
@@ -108,7 +108,22 @@ ss -tlnp | grep docker-proxy # 确认对外只有 80/443
---
## 6. 已知遗留问题
## 6. 配置取值格式(踩过的坑)
`.env` / compose 里的取值格式取决于应用侧字段类型:
| 类型 | 写法 | 例子 |
| --- | --- | --- |
| `str`(应用内部再按逗号拆) | 逗号分隔字符串 | `PROD_CORS_ORIGINS=https://a.com,https://b.com` |
| `list[str]`(pydantic-settings 直接解析) | **必须 JSON 数组字面量** | `OAUTH_ALLOWED_HOSTS=["service.fastapiadmin.com","*.fastapiadmin.com"]` |
把 `list[str]` 字段写成逗号分隔字符串会让应用**启动期抛 `SettingsError`**,容器不断重启、网关返回 502。2026-10-04 的部署事故就是这个原因(已修复并在 compose 默认值里固化 JSON 形态)。
排查方式:`docker logs --tail 40 backend`,若看到
`pydantic_settings.exceptions.SettingsError: error parsing value for field "XXX" from source "EnvSettingsSource"`,
就是该字段的取值格式与类型不匹配。
## 7. 已知遗留问题
安全与质量债的完整清单见 `AUDIT-REPORT.md` 与各分报告(`backend/audit-*.md`、`frontend/audit-*.md`、`docker/audit-deploy.md`)。当前仍待处理的高优先级项:
+13
View File
@@ -45,6 +45,19 @@ PROD_CORS_ORIGINS=https://service.fastapiadmin.com
FORWARDED_ALLOW_IPS=10.255.178.0/24
# 演示模式:true 时禁止写操作;生产需要可写请改为 false
DEMO_ENABLE=true
# Host 头校验范围:false 时 ALLOWED_HOSTS 只保留生产域名(不并入 localhost)。
# 容器健康检查已配置为发送正确的 Host,因此生产建议 false;
# 若你的健康检查/探针确实以 Host: localhost 访问容器,再设为 true。
ALLOW_LOCALHOST_HOSTS=false
# OAuth 回调与换取令牌允许的 Host。
# ⚠️ 该字段在应用里是 list[str]:必须写 **JSON 数组**,写成逗号分隔字符串会让服务启动失败
# (pydantic-settings 解析报 SettingsError,容器会不断重启)。
# 逗号分隔只适用于 str 类型字段,例如上面的 PROD_CORS_ORIGINS。
OAUTH_ALLOWED_HOSTS=["service.fastapiadmin.com","*.fastapiadmin.com"]
# 生产未配置 PROD_CORS_ORIGINS 时是否直接启动失败:false=仅告警(默认),true=严格
CORS_STRICT_STARTUP=false
# 定时任务是否允许执行用户提交的代码块(等同 RCE 能力,生产保持 false)
SCHEDULER_ALLOW_CODE_EXEC=false
# ---------- Nginx ----------
HTTP_PORT=80
+15 -1
View File
@@ -128,6 +128,17 @@ services:
PROD_CORS_ORIGINS: "${PROD_CORS_ORIGINS:-}"
# 演示模式:为 true 时禁止写操作。当前线上为 true,如需可写请设为 false
DEMO_ENABLE: "${DEMO_ENABLE:-true}"
# Host 头校验范围:false 时 ALLOWED_HOSTS 收敛为仅生产域名(推荐)。
# 容器健康检查已改为发送正确 Host,因此这里可以为 false。
ALLOW_LOCALHOST_HOSTS: "${ALLOW_LOCALHOST_HOSTS:-true}"
# OAuth 回调/换取令牌允许的 Host。
# 注意:应用里该字段是 list[str],pydantic-settings 要求 **JSON 数组**字面量,
# 写成逗号分隔字符串会导致启动期 SettingsError(已踩过,服务会起不来)。
OAUTH_ALLOWED_HOSTS: '${OAUTH_ALLOWED_HOSTS:-["service.fastapiadmin.com","*.fastapiadmin.com"]}'
# 生产未配置 PROD_CORS_ORIGINS 时是否直接启动失败(默认 false:仅告警并按空列表处理)
CORS_STRICT_STARTUP: "${CORS_STRICT_STARTUP:-false}"
# 定时任务是否允许执行用户提交的代码块(等同 RCE 能力,生产保持 false)
SCHEDULER_ALLOW_CODE_EXEC: "${SCHEDULER_ALLOW_CODE_EXEC:-false}"
ports:
# 仅绑定本机:数据面不对公网开放,容器之间经 app_network 互联
- "127.0.0.1:${BACKEND_PORT:-8001}:8001"
@@ -147,11 +158,14 @@ services:
condition: service_healthy
networks:
- app_network
# 健康检查必须带正确的 Host 头:应用启用了 TrustedHostMiddleware,
# Host 为 localhost 会被判 400;这里用「发正确 Host」而不是「放宽 ALLOWED_HOSTS」,
# 避免为了容器自检而降低对外 Host 校验强度。
healthcheck:
test:
[
"CMD-SHELL",
"python -c 'import urllib.request; req = urllib.request.Request(\"http://localhost:8001/api/v1/monitor/health/check\", headers={\"X-Forwarded-Proto\": \"https\"}); urllib.request.urlopen(req, timeout=5)' || exit 1",
"python -c 'import urllib.request; req = urllib.request.Request(\"http://localhost:8001/api/v1/monitor/health/check\", headers={\"Host\": \"service.fastapiadmin.com\", \"X-Forwarded-Proto\": \"https\"}); urllib.request.urlopen(req, timeout=5)' || exit 1",
]
interval: 15s
timeout: 10s