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

11 KiB
Raw Blame History

安全审计报告 — 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-81backend/internal/model/finance.go:20(关联定义只按 foreignKey,无 shop 作用域)
  • backend/internal/handler/stock_in.go:208-212Get Preload Partner/Warehouse
  • backend/internal/handler/stock_out.go:142-144Get Preload Partner/Warehouse
  • backend/internal/handler/finance.go:70ListRecords Preload Partner)、finance.go:232-245Summary LEFT JOIN partners p ON p.id = f.partner_id,无 p.shop_id 约束)
  • 写入侧不校验归属:stock_in.go:220-268Create 直接透传 req.PartnerID/req.WarehouseID)、stock_out.go:190-239finance.go:81-127Create 透传 partner_id

描述:下单/建财务记录接口接收请求体里的 partner_idwarehouse_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_idwarehouse_id 归属当前 shop_idSELECT 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.CreateCheckitem.ProductID 未验归属)→ GetCheck/CompleteCheckPreload("Items.Product") 会带出他店 product。

验证方式:用 A、B 两店 tokenA 建单引用 B 的 partner_idGET 单据确认 partner 为 null 或 400,而非返回 B 店数据。


High — 本次发布前修复

SEC-002: 默认 JWT / License 密钥随仓库提交,且 release 模式无校验

文件backend/config/config.yaml:10,15backend/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.envJWT_SECRET 覆盖,但一旦漏配 env / 配置加载顺序异常,服务会以仓库里公开的默认密钥启动。 攻击场景:攻击者用公开的默认 secret 自签任意 {user_id, shop_id, role:"superadmin"} 的 JWTmiddleware/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 ...,去掉 -pheredoc 用单引号 << 'EOF' 让密码只在远端环境变量里展开,不进命令行。 验证方式:远端 ps -ef | grep mysql 执行期间看不到密码;CI 日志无密码串。


Medium — 近期修复

SEC-004: finance / 库存盘点写入未校验外键归属(SEC-001 写入侧的独立体现)

文件backend/internal/handler/finance.go:81-127backend/internal/handler/inventory.go:239-271 描述:即便按 SEC-001 修好读取侧 Preload,写入侧仍应独立校验:finance.Create 存任意 partner_idCreateCheck 存任意 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-68config.go:93 描述:默认 cors_origin: "*"release 模式已 fatal 拦截 *main.go:25-27),debug 模式放开属预期。当前无 Allow-Credentials,风险低。保持现状即可,备案。

SEC-010: 公开商品接口暴露面

文件backend/internal/handler/public.goGetProduct/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.EscapeStringpublic.go:355,411-418),无 XSS。暴露面可接受,备案。


通过检查项

  • 本会话新增代码多租户隔离到位:summaryBounds/rolling30stockIn/Out.Summarystock_in.go:191-200/stock_out.go:90-99)、finance.Trendfinance.go:198-205)、inventory.Summaryinventory.go:187-207)所有聚合均带 shop_id = ?
  • 库存/出入库搜索的 EXISTS/IN 子查询含 it.shop_id = ?stock_in.go:63-68,139-144stock_out.go:59-65inventory.go:115-121);itemsTable 为硬编码常量非用户输入(stock_in.go:87 注释确认),series/spec 多值用 strings.Repeat("?,") 生成占位符后参数化,无拼接注入。
  • 所有 LIKE/keyword 均走 GORM ? 参数化,未见字符串拼接进 SQL。
  • ?window 参数只用 == "rolling30" 相等判断,不参与 SQLstock_in.go:173);monthsstrconv.Atoi + 1..24 范围校验(finance.go:186)。
  • 新增路由 /finance/trendrouter.go:216)在 api.Use(JWT) + ReadOnly + LicenseGuard 组内,为 GET 只读;所有写操作路由都在 ReadOnly() 之后;/usersAdminOnly/admin/*SuperAdminOnly
  • 密码 bcrypt 哈希(service/auth.go:443,496),PasswordHashjson:"-"model/user.go:9)不外泄。
  • JWT 会话校验支持踢人/禁用即时失效(middleware/auth.go:56-80),登录按账号+IP 双重失败锁定、多档 IP/店限流。
  • 库存扣减/单号生成用事务 + FOR UPDATE 行锁(inventory.go:348service/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.shMYSQL_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)。