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

103 lines
11 KiB
Markdown
Raw 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
**审计范围**:后端 `backend/`router / 全部 handler / middleware / service / config / model)与部署链路(`.gitea/workflows/*``scripts/ci/*``deploy/*`)。重点核查本会话新增的 `summaryBounds`/`rolling30`(出入库 KPI)、`finance.Trend`、库存/出入库搜索的 `EXISTS/IN` 子查询与 LIKE 拼接、`?window` 参数处理、新增路由鉴权、CI secrets 使用、`backup-db.sh` 密码处理、公开端点暴露面。
**发现问题**Critical 1High 2Medium 3Low 4
---
## Critical — 必须立即修复
### SEC-001: 关联对象(Partner / Warehouse)跨租户信息泄露
**文件**
- `backend/internal/model/stock.go:26-27,80-81``backend/internal/model/finance.go:20`(关联定义只按 `foreignKey`,无 shop 作用域)
- `backend/internal/handler/stock_in.go:208-212``Get` Preload Partner/Warehouse
- `backend/internal/handler/stock_out.go:142-144``Get` Preload Partner/Warehouse
- `backend/internal/handler/finance.go:70``ListRecords` Preload Partner)、`finance.go:232-245``Summary` `LEFT JOIN partners p ON p.id = f.partner_id`,无 `p.shop_id` 约束)
- 写入侧不校验归属:`stock_in.go:220-268`Create 直接透传 `req.PartnerID`/`req.WarehouseID`)、`stock_out.go:190-239``finance.go:81-127`Create 透传 `partner_id`
**描述**:下单/建财务记录接口接收请求体里的 `partner_id``warehouse_id`,**不校验这些 ID 是否属于当前门店**。读取时用 GORM `Preload("Partner")` / `Preload("Warehouse")`(或 finance Summary 的 `LEFT JOIN partners`)按外键直接回连,**没有再按 `shop_id` 二次过滤**。因此攻击者能引用其他门店的 `partner_id`/`warehouse_id`,读回时把对方数据带出来。
**攻击场景**:攻击者登录 A 店,POST 一张草稿入库单 `{"warehouse_id":1,"partner_id":<枚举 N>,...}`,随后 GET `/api/v1/stock-in/orders/:id`,响应中的 `partner` 对象会返回 B 店该往来单位的 `name / contact / phone / address / bank_account / credit_limit / balance`(见 `model/partner.go` 字段)。循环枚举 `partner_id` 即可导出全平台所有门店的客户/供应商联系人、电话、地址、银行账号——多租户隔离被彻底击穿,且泄露 PII。finance `ListRecords`/`Summary` 同理会带出他店往来单位名。
**修复建议**
1. 写入侧:Create/Update 时校验 `partner_id``warehouse_id` 归属当前 `shop_id``SELECT 1 FROM partners WHERE id=? AND shop_id=?`),不符则 400。
2. 读取侧纵深防御:Preload 加租户条件,如 `Preload("Partner", "shop_id = ?", shopID)``Preload("Warehouse", "shop_id = ?", shopID)`finance Summary/ListRecords 的 join/preload 同样补 `p.shop_id = f.shop_id`
3. 同类需一并修:`inventory.CreateCheck`item.ProductID 未验归属)→ `GetCheck`/`CompleteCheck``Preload("Items.Product")` 会带出他店 product。
**验证方式**:用 A、B 两店 tokenA 建单引用 B 的 partner_idGET 单据确认 `partner` 为 null 或 400,而非返回 B 店数据。
---
## High — 本次发布前修复
### SEC-002: 默认 JWT / License 密钥随仓库提交,且 release 模式无校验
**文件**`backend/config/config.yaml:10,15``backend/main.go:24-28`
**描述**`config.yaml`(已进 git)内含 `jwt.secret: "change-this-to-a-random-secret-in-production"``license.hmac_secret: "change-this-license-secret-in-production"``main.go` 仅在 release 模式校验 `cors_origin != "*"`**未校验 JWT secret 是否仍为默认值**。生产靠 `production.env``JWT_SECRET` 覆盖,但一旦漏配 env / 配置加载顺序异常,服务会以仓库里公开的默认密钥启动。
**攻击场景**:攻击者用公开的默认 secret 自签任意 `{user_id, shop_id, role:"superadmin"}` 的 JWT`middleware/auth.go:46-48` 只验签名),无 sid 的 token 走「存量放行」分支(`auth.go:56` 注释),直接以任意门店超管身份访问全部接口——完全绕过认证。
**修复建议**:在 `main.go` release 前置检查里追加:`jwt.secret` 为空或等于默认占位串时 `log.Fatal`;同理校验 license hmac_secret。默认值不应是可用值。
**验证方式**release 模式下不设 `JWT_SECRET` 启动,应直接 fatal 退出。
### SEC-003: 运维 workflow 在命令行内联 DB 密码(`mysql -p${DB_PASSWORD}`
**文件**`.gitea/workflows/reset-db.yml:38,45,60,64``.gitea/workflows/debug-db.yml:23-42``.gitea/workflows/seed.yml:44,59`
**描述**:这三个 workflow 把 `${{ secrets.DB_PASSWORD }}``-p${DB_PASSWORD}` 形式内联进通过 SSH 下发到生产/容器执行的 `mysql`/`docker exec` 命令。`debug-db.yml` 用未加引号的 `<< EOF` heredoc,密码在 runner 本地展开后拼进远端命令串。风险:(a) 目标主机 `ps aux` 可见明文密码;(b) mysql 会打印 "password on the command line is insecure"(c) 若任一步骤开 `set -x` 或命令回显,CI 日志泄露密码。对比 `scripts/ci/backup-db.sh:33-34` 已用 `MYSQL_PWD` env 正确处理——说明团队知道正确姿势,只是这三个 workflow 没跟上。
**修复建议**:改为 `docker exec -e MYSQL_PWD="$DB_PASSWORD" jiu_mysql mysql -uroot ...`,去掉 `-p`heredoc 用单引号 `<< 'EOF'` 让密码只在远端环境变量里展开,不进命令行。
**验证方式**:远端 `ps -ef | grep mysql` 执行期间看不到密码;CI 日志无密码串。
---
## Medium — 近期修复
### SEC-004: finance / 库存盘点写入未校验外键归属(SEC-001 写入侧的独立体现)
**文件**`backend/internal/handler/finance.go:81-127``backend/internal/handler/inventory.go:239-271`
**描述**:即便按 SEC-001 修好读取侧 Preload,写入侧仍应独立校验:`finance.Create` 存任意 `partner_id``CreateCheck` 存任意 `product_id`,会在本店数据里留下指向他店实体的脏引用,破坏数据一致性与后续对账。
**修复建议**:所有接收外键的写接口统一做「归属当前 shop」前置校验,抽成公共 helper。
### SEC-005: 公开错误上报端点可伪造身份字段、`error_msg` 无长度上限
**文件**`backend/internal/handler/error_report.go:19-72`、路由 `router.go:78`
**描述**`POST /api/v1/public/errors` 无需认证,`shop_id / username / role / shop_no` 全部取自请求体,攻击者可任意伪造,污染 superadmin 后台的错误列表(`admin/errors`)。`stack_trace` 截断到 4000,但 `error_msg` 未设上限,可提交超大 body 撑存储(仅 IP 限流 30/min 缓解)。
**修复建议**`error_msg` 加长度上限(如 2000);后台展示对这些「客户端自报字段」标注不可信;考虑对 `shop_id` 做存在性校验或改标为 `reported_shop_id`
### SEC-006: 生产库密码经 `python3 re.search` 从 DSN 裸解析,DSN 特殊字符易误解析
**文件**`scripts/ci/backup-db.sh:33`
**描述**`re.match(r"[^:]+:([^@]+)@", d)` 从 DSN 提取密码,若密码含 `@``:` 会截断/错取,导致备份静默失败(虽有 100KB 体积兜底,但错误密码下 dump 会直接失败退出,属可用性风险而非泄露)。当前实现本身不泄露密码(走 `MYSQL_PWD`,正确)。
**修复建议**:改为在远端 source `production.env` 后用 shell 参数解析,或约束 DB 密码字符集;此条为健壮性加固。
---
## Low — 备案,酌情处理
### SEC-007: 兑换码熵与取模偏置
**文件**`backend/internal/util/redeem_code.go:16-31`
**描述**:兑换码 8 位 × 31 字母表 ≈ 40 bit;`int(b)%len(codeAlphabet)`(256 % 31 ≠ 0)引入轻微取模偏置,进一步压缩有效熵。`/license/activate` 需登录且受 per-shop 限流(20rps),在线爆破 40bit 不现实,风险低。
**修复建议**:用 `rand.Int(rand.Reader, big.NewInt(len))` 消除偏置;如担心批量猜码可加每店激活失败限速。
### SEC-008: `product_option` / `product_attr` keyword 搜索 LIKE 通配未转义
**文件**`backend/internal/handler/product_option.go:31,97,163,231`
**描述**`"%"+kw+"%"` 参数化传入(无注入),但用户传入的 `%`/`_` 会被当通配符,可致模糊匹配范围异常(功能性,非安全边界)。同现象遍布各列表搜索。
**修复建议**:如需精确前缀/包含语义,转义 `%``_`
### SEC-009: CORS 允许头未含限流/凭证约束,`*` 仅靠 release 守卫
**文件**`backend/main.go:57-68``config.go:93`
**描述**:默认 `cors_origin: "*"`release 模式已 fatal 拦截 `*``main.go:25-27`),debug 模式放开属预期。当前无 `Allow-Credentials`,风险低。保持现状即可,备案。
### SEC-010: 公开商品接口暴露面
**文件**`backend/internal/handler/public.go``GetProduct`/`ListShopProducts`/`ProductPage`)、`version.go``/health`
**描述**`/health` 仅回 `{"status":"ok"}``/version``/public/release` 实时读 `version.yaml`(无敏感信息)。`public/shops/:shop_code/products` 按 shop_code 列出有库存商品(含 sale_price)——属商品公开展示的设计意图,已加最紧 IP 限流(`shop_list_per_min`)。`ProductPage` 注入的 OG 字段均经 `html.EscapeString``public.go:355,411-418`),无 XSS。暴露面可接受,备案。
---
## 通过检查项
- ✅ 本会话新增代码多租户隔离到位:`summaryBounds`/`rolling30``stockIn/Out.Summary``stock_in.go:191-200`/`stock_out.go:90-99`)、`finance.Trend``finance.go:198-205`)、`inventory.Summary``inventory.go:187-207`)所有聚合均带 `shop_id = ?`
- ✅ 库存/出入库搜索的 `EXISTS/IN` 子查询含 `it.shop_id = ?``stock_in.go:63-68,139-144``stock_out.go:59-65``inventory.go:115-121`);`itemsTable` 为硬编码常量非用户输入(`stock_in.go:87` 注释确认),`series/spec` 多值用 `strings.Repeat("?,")` 生成占位符后参数化,无拼接注入。
- ✅ 所有 LIKE/keyword 均走 GORM `?` 参数化,未见字符串拼接进 SQL。
-`?window` 参数只用 `== "rolling30"` 相等判断,不参与 SQL`stock_in.go:173`);`months``strconv.Atoi` + `1..24` 范围校验(`finance.go:186`)。
- ✅ 新增路由 `/finance/trend``router.go:216`)在 `api.Use(JWT)` + `ReadOnly` + `LicenseGuard` 组内,为 GET 只读;所有写操作路由都在 `ReadOnly()` 之后;`/users``AdminOnly``/admin/*``SuperAdminOnly`
- ✅ 密码 bcrypt 哈希(`service/auth.go:443,496`),`PasswordHash``json:"-"``model/user.go:9`)不外泄。
- ✅ JWT 会话校验支持踢人/禁用即时失效(`middleware/auth.go:56-80`),登录按账号+IP 双重失败锁定、多档 IP/店限流。
- ✅ 库存扣减/单号生成用事务 + `FOR UPDATE` 行锁(`inventory.go:348``service/stock.go` GenerateOrderNo);`admin.ClearData` 用表名白名单 + `shop_id` 过滤(`admin.go:30-51`)。
- ✅ CI secrets 通过 `${{ secrets.* }}` 注入 env,未见 `echo`/`set -x` 打印密钥;`setup_ssh` 私钥写盘后 `teardown_ssh`/`if: always()` 清理(`lib-forgejo.sh:128-130`、各 workflow cleanup 步骤)。`backup-db.sh``MYSQL_PWD` env 传密码(正确)。
-`SetTrustedProxies(127.0.0.1/::1)` + `RemoteIPHeaders=X-Real-IP`,客户端无法伪造限流/审计所依赖的真实 IP(`main.go:54-55`)。
- ✅ 图片上传校验:解码校验真实格式、限 1MB、限每商品 5 张、UUID 文件名、按 productID 归档(`product_image.go:49-96`)。