Files
jiu/docs/security/2026-07-03-precommit-audit.md

91 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 安全审计报告(发版前增量) — 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 0High 0Medium 2Low 4
**总体结论**:首轮审计的 CriticalSEC-001 跨租户泄露)与 HighSEC-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_idGET 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`)保持不变。