Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
11 KiB
安全审计报告 — 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(GetPreload Partner/Warehouse)backend/internal/handler/stock_out.go:142-144(GetPreload Partner/Warehouse)backend/internal/handler/finance.go:70(ListRecordsPreload Partner)、finance.go:232-245(SummaryLEFT 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 同理会带出他店往来单位名。
修复建议:
- 写入侧:Create/Update 时校验
partner_id、warehouse_id归属当前shop_id(SELECT 1 FROM partners WHERE id=? AND shop_id=?),不符则 400。 - 读取侧纵深防御:Preload 加租户条件,如
Preload("Partner", "shop_id = ?", shopID)、Preload("Warehouse", "shop_id = ?", shopID);finance Summary/ListRecords 的 join/preload 同样补p.shop_id = f.shop_id。 - 同类需一并修:
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.goGenerateOrderNo);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_PWDenv 传密码(正确)。 - ✅
SetTrustedProxies(127.0.0.1/::1)+RemoteIPHeaders=X-Real-IP,客户端无法伪造限流/审计所依赖的真实 IP(main.go:54-55)。 - ✅ 图片上传校验:解码校验真实格式、限 1MB、限每商品 5 张、UUID 文件名、按 productID 归档(
product_image.go:49-96)。