d5b11c0943
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
103 lines
11 KiB
Markdown
103 lines
11 KiB
Markdown
# 安全审计报告 — 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 1,High 2,Medium 3,Low 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 两店 token;A 建单引用 B 的 partner_id,GET 单据确认 `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`)。
|