d5b11c0943
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
91 lines
13 KiB
Markdown
91 lines
13 KiB
Markdown
# 安全审计报告(发版前增量) — 2026-07-03
|
||
|
||
**审计范围**:分支 `worktree-front-refactor` 的全部未提交改动(`git diff HEAD` + 未跟踪文件),聚焦**本日首轮审计(`docs/security/2026-07-03-audit.md`,Critical/High 已修复)之后**的新增/变更部分:
|
||
|
||
1. `client/lib/screens/auth/`(登录/注册屏全新重写)+ `client/lib/core/storage/login_history.dart`(记住我)+ `client/lib/repositories/auth_repository.dart`
|
||
2. `deploy/nginx-jiu-ali.conf`(443 TLS 回切 + 80→443 跳转,8443 拆除)
|
||
3. `scripts/ci/*` + `.gitea/workflows/*`(EC2→Ali URL/secret 回切)
|
||
4. 三端明文豁免(ATS / usesCleartextTraffic)还原核查
|
||
5. `backend/` 改动回归扫描(新增 `handler/ownership.go`、多租户 Preload/写入侧校验、`main.go` SEC-002 修复)
|
||
|
||
**发现问题**:Critical 0,High 0,Medium 2,Low 4
|
||
|
||
**总体结论**:首轮审计的 Critical(SEC-001 跨租户泄露)与 High(SEC-002 默认密钥、SEC-003 CI 内联 DB 密码)在本轮改动中**均已闭环修复且实现正确**。新增的登录/注册屏、nginx 回切、CI 回切**未引入新的 Critical/High**。剩余为纵深加固项(HSTS、盘点写入侧归属校验、商品字典外键归属),可发版后处理。
|
||
|
||
---
|
||
|
||
## Critical — 必须立即修复
|
||
|
||
无。
|
||
|
||
---
|
||
|
||
## High — 本次发布前修复
|
||
|
||
无。
|
||
|
||
---
|
||
|
||
## Medium — 近期修复
|
||
|
||
### SEC-P01: 盘点建单(CreateCheck)写入侧未校验 product_id / warehouse_id 归属
|
||
**文件**:`backend/internal/handler/inventory.go:239-273`(`CreateCheck`,无 `ensureShopRef`)、`inventory.go:307-345`(`CompleteCheck` 盘盈按 `item.ProductID` 建 `Inventory` 行)
|
||
**描述**:本轮为 stock-in / stock-out / finance 的写接口补齐了 `ensureShopRef`/`ensureShopRefOpt` 外键归属校验(`ownership.go`),但**盘点建单 `CreateCheck` 漏了同款校验**——`req.Items[i].ProductID` 与 `req.WarehouseID` 直接透传入库。首轮审计 SEC-001 修复说明第 3 条已点名此处待修,读取侧 `Preload("Items.Product", "shop_id = ?", shopID)` 现已补上(跨店 product 读不出来),但写入侧仍缺。
|
||
**攻击/影响场景**:攻击者登录 A 店,`POST /api/v1/inventory/checks` 提交 `items:[{product_id:<B 店的 id>}]`;随后 `CompleteCheck` 走盘盈分支会在 **A 店 inventory 表**新建一行 `product_id=B店id` 的库存记录(`inventory.go:331-343`),snapshot 列因 `item.Product` 被 shop 作用域 Preload 置 nil 而为空。结果:不是直接跨租户读取(读侧已挡),而是**在本店数据里留下指向他店 product 的脏引用**,破坏库存一致性与后续对账/显示兜底。盘亏分支 FIFO 扣减带 `shop_id AND product_id` 双条件,跨店 id 匹配不到批次,无副作用。
|
||
**修复建议**:在 `CreateCheck` 复用 `ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID)` 与逐条 `ensureShopRef(h.db, "products", item.ProductID, shopID)`,不符即 400。与 stock-in/out 保持同一道防线。
|
||
**验证方式**:A 店 token 提交引用 B 店 product_id 的盘点单,应 400 拒绝,而非建单成功。
|
||
|
||
### SEC-P02: nginx 443 回切后缺失 HSTS,且 TLS/安全响应头未加固
|
||
**文件**:`deploy/nginx-jiu-ali.conf:15-23`(server 块无 `Strict-Transport-Security` / `ssl_prefer_server_ciphers` / `X-Frame-Options` / `X-Content-Type-Options`)
|
||
**描述**:8443 明文过渡口刚拆除、回切 443 TLS(`ssl_protocols TLSv1.2 TLSv1.3` 正确,已排除 TLSv1.0/1.1),并新增 80→443 跳转(`:107-112`)。但:
|
||
- **无 HSTS**:首次通过 80 访问的用户在被 301 跳转前存在一次明文窗口,可被 SSL-strip 中间人拦截(尤其刚从明文 8443 时代迁移、部分设备可能仍缓存 http 直连习惯)。
|
||
- `ssl_ciphers HIGH:!aNULL:!MD5` 较宽松,未配 `ssl_prefer_server_ciphers on`,未启用 OCSP stapling / `ssl_session_cache`,仍允许部分 TLSv1.2 CBC 套件。
|
||
- 无 `X-Content-Type-Options: nosniff` / `X-Frame-Options`(`/app/` 管理端 + 营销站 `location /` 都由此块服务,存在点击劫持/嗅探面)。
|
||
**影响**:非直接可利用漏洞,属传输层与浏览器侧纵深加固缺口;在「刚结束明文过渡」的时点尤其值得补 HSTS。
|
||
**修复建议**:443 server 块加 `add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;`(确认所有 51yanmei 子域都已上 HTTPS 后再考虑 `preload`);补 `ssl_prefer_server_ciphers on;` + 收敛 cipher 列表(Mozilla intermediate);对 `/app/` 与 `location /` 补 `X-Content-Type-Options nosniff`、`X-Frame-Options SAMEORIGIN`。
|
||
**验证方式**:`curl -sI https://jiu.51yanmei.com/ | grep -i strict-transport`;SSL Labs 评级 A 以上。
|
||
|
||
---
|
||
|
||
## Low — 备案,酌情处理
|
||
|
||
### SEC-P03: 商品字典外键(origin/shelf_life/storage/category/description_doc)无归属校验,读侧 Preload 未按 shop_id 作用域
|
||
**文件**:`backend/internal/handler/product.go:193-206`(Update 透传 `category_id/origin_id/shelf_life_id/storage_id/description_doc_id`,无 `ensureShopRef`)、`product.go:224-225`(Detail `Preload("Category").Preload("Origin").Preload("ShelfLife").Preload("Storage")` 无 `shop_id` 作用域)、`public.go:40-44,335`(公开页同样无 shop 作用域 Preload)
|
||
**描述**:`ownership.go` 的归属校验模式本轮只覆盖了 partner/warehouse/product,**未延伸到商品字典类外键**。用户可把自家 product 的 `origin_id` 等设成他店字典项 id,Detail/公开页会按外键回连读出他店字典的 `Name/Remark`(这些表 = `TenantBase` 门店级)。注意 `product.go` 本轮未改动,属存量残余;且泄露内容为低敏感字典文本(如「酱香型白酒」「阴凉避光保存」),非 PII。
|
||
**修复建议**:product Create/Update 对上述 5 个外键复用 `ensureShopRefOpt`(对应表 `product_origin_options` / `product_shelf_life_options` / `product_storage_options` / `product_categories` / `product_description_docs`);Detail/public 的 Preload 补 `"shop_id = ?", shopID`(公开页用 product 自身 shop_id)。抽成与 SEC-001 同源的统一收口。
|
||
**验证方式**:A 店 product 设 B 店 origin_id,GET detail 应返回 origin=null 或建单时 400。
|
||
|
||
### SEC-P04: 「记住我」默认开启,门店编号+登录账号明文留在设备 SharedPreferences
|
||
**文件**:`client/lib/core/storage/login_history.dart:15,34-47`(`getRemember` 默认 `true`;`record` 存 hotel code + username 到 `SharedPreferences`)、`login_screen.dart:52,124-139`
|
||
**描述**:`_remember` 默认 `true`,登录成功后把门店编号与登录账号(各最多 5 条)明文写入 `SharedPreferences`,下拉历史展示给下一位使用者。**已正确规避:密码从不落盘/不入历史**(仅内存 `TextEditingController`,`dispose` 释放;无任何 password 日志)——这是本项的关键安全边界,已达标。残余点是共享设备上门店编号/账号可被他人枚举,属账号名泄露(非凭证泄露),且是「记住我」标准取舍。
|
||
**修复建议**:可接受现状。如需更严:共享/多用户场景下默认 `false`,或提供「清除登录历史」入口。
|
||
**验证方式**:登录后检查 `SharedPreferences`,确认 `login_history_*` 只含 shop code / username,无 password 键。
|
||
|
||
### SEC-P05: 自助注册字段无长度上限,可提交超大 body
|
||
**文件**:`backend/internal/service/auth.go:423-431`(`RegisterInput` 仅 `binding:"required"` + 密码 `min=6`,各字段无 `max`)、`handler/auth.go:76-90`
|
||
**描述**:`/api/v1/public/register` 无需认证,`shop_name/address/manager_name/phone/description` 均无长度上限,可提交超大字符串撑存储/日志。已由 per-IP 限流缓解(`register_per_min=5`,`router.go:79`),且创建时套事务;单次滥用成本有限。同类现象亦见于注册可无限建试用门店(业务滥用,非安全漏洞,限流已缓解)。
|
||
**修复建议**:各字段加 `max`(如 name ≤100、address ≤255、phone ≤20、description ≤500);phone 加格式校验。
|
||
**验证方式**:提交 100KB 的 shop_name,应 400 而非入库。
|
||
|
||
### SEC-P06: 443 `default_server` + `server_name "... _"` 使裸 IP/任意 Host 均命中 jiu 块
|
||
**文件**:`deploy/nginx-jiu-ali.conf:17-18`
|
||
**描述**:`listen 443 ssl http2 default_server;` 且 `server_name jiu.51yanmei.com _;`,意味着任意 Host 头(含 `https://182.92.213.171` 裸 IP)都会命中该块并用 jiu 证书应答(证书 CN 不匹配裸 IP,浏览器告警,但 API/静态仍可达)。这是 default_server 的预期行为、也是「存量客户端域名 BASE_URL 回切即恢复」所需,暴露面与回切前一致,无新增可利用点。
|
||
**修复建议**:可接受。如需收紧,可为裸 IP/未知 Host 单设一个 `return 444` 的默认块,仅 jiu 域名走业务块。
|
||
**验证方式**:`curl -k -H 'Host: x' https://<ip>/health` 行为符合预期即可。
|
||
|
||
---
|
||
|
||
## 通过检查项(本轮重点复核)
|
||
|
||
- ✅ **SEC-001 跨租户泄露闭环**:新增 `backend/internal/handler/ownership.go`(`ensureShopRef`/`ensureShopRefOpt`,table 为调用点硬编码常量、非用户输入)。写入侧已挂:`stock_in.go:235-240,301-306`(Create/Update warehouse+partner)、`stock_out.go:206-211,273-278`、`finance.go:97`(Create partner)。读取侧 Preload 全部补 `"shop_id = ?", shopID`:`stock_in.go:133-136,209-211`、`stock_out.go:145-149,271-273`、`finance.go:19`(ListRecords)、`finance.go:244`(Summary `LEFT JOIN partners ... AND p.shop_id = f.shop_id`)、`inventory.go:233-234,278,293`(Logs/GetCheck/CompleteCheck)。partner/warehouse 双向防线完整。
|
||
- ✅ **SEC-002 默认 JWT 密钥**:`main.go:28-33` release 模式新增前置校验,`jwt.secret` 为空或等于仓库默认占位串即 `log.Fatal`,杜绝带公开密钥上线。
|
||
- ✅ **SEC-003 CI 内联 DB 密码**:`debug-db.yml:28-40` / `reset-db.yml:31,59-64` / `seed.yml:43,58` 已全部改为 `docker exec jiu_mysql sh -c 'exec mysql -uroot -p\$MYSQL_ROOT_PASSWORD ...'`——密码从**容器内环境变量**读取,不再以 `-p${DB_PASSWORD}` 出现在下发到远端的命令行,且从 workflow 移除了 `DB_PASSWORD` secret 依赖。远端 `ps` 与 CI 日志均不再暴露密码。`backup-db.sh:31-33` 仍走 `MYSQL_PWD` env(正确)。
|
||
- ✅ **CI/workflow 回切无 secret 泄漏**:EC2→Ali 改用 `ALI_SSH_KEY/ALI_HOST/ALI_USER`(`backup.yml`/`reset-db.yml`/`seed.yml`/`debug-db.yml`/`manual.yml`),经 `${{ secrets.* }}` 注入 env,无 echo/`set -x` 打印。`manual.yml:31-40` 将旧 `EC2_*` 同值指向 Ali(回滚旧 tag 时不复活 EC2),`setup_ssh` 私钥写盘后 `teardown_ssh`/cleanup 清理。`notify.sh`/`release-client.sh`/`deploy-server.sh`/`local_test.sh` 仅注释/URL 说明变更,全部 `https://jiu.51yanmei.com`,无明文残留。
|
||
- ✅ **三端明文豁免已还原且无残留**:`git diff HEAD` 对 `client/android/app/src/main/AndroidManifest.xml`、`client/ios/Runner/Info.plist` 无改动;全仓 grep `usesCleartextTraffic`/`cleartext`/`network_security_config`/`NSAllowsArbitraryLoads`/`NSExceptionDomains` 于 `client/android`、`client/ios`、`client/macos` 均**零命中**。前端 `AppConfig` 基址走 `https://` 域名,回切后自动恢复加密传输。
|
||
- ✅ **nginx 回切暴露面收敛**:8443 明文口拆除(减少一个明文入口);443 `ssl_protocols TLSv1.2 TLSv1.3`(已排除弱协议);未鉴权 `(public|auth)/`、`/product/`、`/app/product/` 均挂 `limit_req zone=jiu_pub`;`X-Real-IP $remote_addr` 透传供后端限流/审计取真实 IP。
|
||
- ✅ **登录/注册屏无凭证泄露**:密码仅存内存 `TextEditingController`,`dispose` 释放;`login_history.dart` 只存门店编号+账号,**不存密码**;`api_client.dart:135-139` 的 5xx 上报只发 `[method] path → status`,**不含请求体**(登录失败为 401 不触发上报,即便 5xx 也不带 password);注册成功弹窗仅回显门店编号,无敏感信息。
|
||
- ✅ **shop_id 全程取自 JWT**:登录/注册屏及后端改动无从请求参数读取 `shop_id` 的路径;所有查询经 `middleware.GetShopID(c)`。
|
||
- ✅ **注册鉴权与限流**:`/public/register` 挂 `registerIP`(5/min/IP);`/auth/login` 挂 `loginIP`(10/min/IP);密码入库 bcrypt(`service/auth.go:443`)。
|
||
- ✅ **会话失效提示链路**:`login_screen.dart:116-120,334-340` 首帧主动读 + `ref.listen` 双通道捕获 `sessionEndedMessageProvider`,被顶下线有弹窗;消息内容无敏感信息。
|
||
- ✅ **model 改动为纯 gofmt 对齐**:`user_session.go`/`license_device.go`/`license.go` 等仅列对齐空白变更,无语义/字段/tag 改动,多租户索引与 `json:"-"`(`RefreshJTI`)保持不变。
|