Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
13 KiB
安全审计报告(发版前增量) — 2026-07-03
审计范围:分支 worktree-front-refactor 的全部未提交改动(git diff HEAD + 未跟踪文件),聚焦本日首轮审计(docs/security/2026-07-03-audit.md,Critical/High 已修复)之后的新增/变更部分:
client/lib/screens/auth/(登录/注册屏全新重写)+client/lib/core/storage/login_history.dart(记住我)+client/lib/repositories/auth_repository.dartdeploy/nginx-jiu-ali.conf(443 TLS 回切 + 80→443 跳转,8443 拆除)scripts/ci/*+.gitea/workflows/*(EC2→Ali URL/secret 回切)- 三端明文豁免(ATS / usesCleartextTraffic)还原核查
backend/改动回归扫描(新增handler/ownership.go、多租户 Preload/写入侧校验、main.goSEC-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(SummaryLEFT 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-33release 模式新增前置校验,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_PASSWORDsecret 依赖。远端ps与 CI 日志均不再暴露密码。backup-db.sh:31-33仍走MYSQL_PWDenv(正确)。 - ✅ 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无改动;全仓 grepusesCleartextTraffic/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)保持不变。