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

13 KiB
Raw Permalink Blame History

安全审计报告(发版前增量) — 2026-07-03

审计范围:分支 worktree-front-refactor 的全部未提交改动(git diff HEAD + 未跟踪文件),聚焦本日首轮审计(docs/security/2026-07-03-audit.mdCritical/High 已修复)之后的新增/变更部分:

  1. client/lib/screens/auth/(登录/注册屏全新重写)+ client/lib/core/storage/login_history.dart(记住我)+ client/lib/repositories/auth_repository.dart
  2. deploy/nginx-jiu-ali.conf443 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-273CreateCheck,无 ensureShopRef)、inventory.go:307-345CompleteCheck 盘盈按 item.ProductIDInventory 行) 描述:本轮为 stock-in / stock-out / finance 的写接口补齐了 ensureShopRef/ensureShopRefOpt 外键归属校验(ownership.go),但盘点建单 CreateCheck 漏了同款校验——req.Items[i].ProductIDreq.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-23server 块无 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 nosniffX-Frame-Options SAMEORIGIN验证方式curl -sI https://jiu.51yanmei.com/ | grep -i strict-transportSSL Labs 评级 A 以上。

Low — 备案,酌情处理

SEC-P03: 商品字典外键(origin/shelf_life/storage/category/description_doc)无归属校验,读侧 Preload 未按 shop_id 作用域

文件backend/internal/handler/product.go:193-206Update 透传 category_id/origin_id/shelf_life_id/storage_id/description_doc_id,无 ensureShopRef)、product.go:224-225Detail 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-47getRemember 默认 truerecord 存 hotel code + username 到 SharedPreferences)、login_screen.dart:52,124-139 描述_remember 默认 true,登录成功后把门店编号与登录账号(各最多 5 条)明文写入 SharedPreferences,下拉历史展示给下一位使用者。已正确规避:密码从不落盘/不入历史(仅内存 TextEditingControllerdispose 释放;无任何 password 日志)——这是本项的关键安全边界,已达标。残余点是共享设备上门店编号/账号可被他人枚举,属账号名泄露(非凭证泄露),且是「记住我」标准取舍。 修复建议:可接受现状。如需更严:共享/多用户场景下默认 false,或提供「清除登录历史」入口。 验证方式:登录后检查 SharedPreferences,确认 login_history_* 只含 shop code / username,无 password 键。

SEC-P05: 自助注册字段无长度上限,可提交超大 body

文件backend/internal/service/auth.go:423-431RegisterInputbinding:"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=5router.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.goensureShopRef/ensureShopRefOpt,table 为调用点硬编码常量、非用户输入)。写入侧已挂:stock_in.go:235-240,301-306Create/Update warehouse+partner)、stock_out.go:206-211,273-278finance.go:97Create partner)。读取侧 Preload 全部补 "shop_id = ?", shopIDstock_in.go:133-136,209-211stock_out.go:145-149,271-273finance.go:19ListRecords)、finance.go:244Summary LEFT JOIN partners ... AND p.shop_id = f.shop_id)、inventory.go:233-234,278,293Logs/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_USERbackup.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 HEADclient/android/app/src/main/AndroidManifest.xmlclient/ios/Runner/Info.plist 无改动;全仓 grep usesCleartextTraffic/cleartext/network_security_config/NSAllowsArbitraryLoads/NSExceptionDomainsclient/androidclient/iosclient/macos零命中。前端 AppConfig 基址走 https:// 域名,回切后自动恢复加密传输。
  • nginx 回切暴露面收敛:8443 明文口拆除(减少一个明文入口);443 ssl_protocols TLSv1.2 TLSv1.3(已排除弱协议);未鉴权 (public|auth)//product//app/product/ 均挂 limit_req zone=jiu_pubX-Real-IP $remote_addr 透传供后端限流/审计取真实 IP。
  • 登录/注册屏无凭证泄露:密码仅存内存 TextEditingControllerdispose 释放;login_history.dart 只存门店编号+账号,不存密码api_client.dart:135-139 的 5xx 上报只发 [method] path → status不含请求体(登录失败为 401 不触发上报,即便 5xx 也不带 password);注册成功弹窗仅回显门店编号,无敏感信息。
  • shop_id 全程取自 JWT:登录/注册屏及后端改动无从请求参数读取 shop_id 的路径;所有查询经 middleware.GetShopID(c)
  • 注册鉴权与限流/public/registerregisterIP5/min/IP);/auth/loginloginIP10/min/IP);密码入库 bcryptservice/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)保持不变。