# SecurityEngineer — 幽灵 spec 安全审计汇总报告 **文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/` **审计时间**:2026-04-20 **参与者**:SecurityEngineer(安全审计)、BackendArchitect(根因分析)、FrontendDev(前端分析) --- ## 执行摘要 对「场馆删除后编辑商品出现规格重复错误」问题进行了三方安全审计。核心根因已定位,**P1 安全缺陷**已识别。 --- ## 审计范围 | 文件 | 用途 | |------|------| | `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 商品保存钩子,vr_goods_config 处理 | | `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | 批量 SKU 生成,模板不存在时的 fallback | | `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 顾客端座位选购页面 | | `shopxo/app/plugins/vr_ticket/admin/Admin.php` | 场馆硬删除逻辑 | | `shopxo/app/admin/hook/AdminGoodsSave.php` | ShopXO 商品保存钩子入口 | --- ## 根因分析(SecurityEngineer) ### 根因 1(P1):无效 template_id 配置块未被过滤 **文件**:`AdminGoodsSaveHandle.php:148-173` 当 `vr_goods_config` 中存在 `template_id` 指向已删除场馆的配置块时: 1. `save_thing_end` 从 DB 加载 config(第 61-66 行) 2. 遍历 configs 尝试重建 `template_snapshot`(第 77 行) 3. 若模板不存在,`continue` 跳过 snapshot 重建(第 88-90 行) 4. **整个 config 块(含旧的 `template_snapshot`)被写回 DB**(第 148-150 行) 5. `BatchGenerate` 被调用时,若 `template_id` 仍为正整数但模板不存在,返回 `code: -2` 阻止保存 **关键缺陷**:若 config 块的 `template_id` 被前端置为 `0`(模板选单为空),则 `templateId > 0` 为 `false`,`BatchGenerate` 整个循环体被跳过,**无任何校验**直接写回。 ### 根因 2(P1):幽灵 spec 持续污染 vr_goods_config 脏 config 块(含已删除模板的 `template_snapshot`)被写回 DB 后: - 下次编辑商品时,`vr_goods_config` 仍含无效配置 - `GetGoodsViewData` 尝试加载模板(失败后将 `template_id` 置 null) - 但若 `save_thing_end` 在模板验证前先执行写回,无效配置再次被保存 - 循环往复,**幽灵 spec 永远无法被清理** ### 根因 3(P2):前端无 `vr_goods_config_base64` 输入保护 `AdminGoodsSaveHandle.php:29-35` 接收前端传入的 base64 编码配置: - 无 schema 校验(不验证 `template_id` 是否为正整数) - 无类型校验(不验证是否为数组) - 管理员可直接 POST 恶意 JSON 注入 `vr_goods_config` --- ## 前端分析(参考 ticket_detail.html) ### 硬删除场景下的 fallback `SeatSkuService::GetGoodsViewData` 在模板不存在时: - `vr_seat_template` 返回 `null` - `goods_config.template_id` 置 `null` - `goods_config.template_snapshot` 置 `null` - `goods_spec_data` 返回空数组 前端 `ticket_detail.html` 读取 `seatMap = []` 和 `specBaseIdMap = []`,座位图不渲染。**设计正确**。 ### 安全风险 1. **`loadSoldSeats()` 未实现**(ticket_detail.html:375-383):TODO 注释状态,无法标记已售座位。顾客可购买已售座位(需支付验证拦截)。 2. **`submit` 依赖 `specBaseIdMap`**(第 417 行):空时降级 `sessionSpecId`。理论上可操控座位数据选择任意座位。 3. **无直接 XSS**:后端输出均有编码,`seatMap` 和 `specBaseIdMap` 来自 DB 合规数据。 --- ## 严重性分级 | 等级 | 数量 | 描述 | |------|------|------| | **P1** | 2 | 无效 template_id 静默保存;幽灵 spec 无法清理 | | **P2** | 3 | Admin API 无 schema 校验;残留 snapshot 信息泄露;specBaseIdMap 端侧无验证 | | **低** | 0 | 无直接 XSS | --- ## 修复方案 ### P1-1/P1-2:拒绝无效 template_id(必须) **AdminGoodsSaveHandle.php:158-173** 需在调用 `BatchGenerate` 前验证: ```php foreach ($configs as $config) { $templateId = intval($config['template_id'] ?? 0); if ($templateId <= 0) { return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除']; } $exists = Db::name('vr_seat_templates')->where('id', $templateId)->find(); if (empty($exists)) { return ['code' => -401, 'msg' => 'template_id [' . $templateId . '] 指向的场馆已不存在']; } $res = SeatSkuService::BatchGenerate(...); if ($res['code'] !== 0) { return $res; } } ``` ### P2-1:过滤无效 config 块(必须) 在写回 DB 之前过滤掉 `template_id <= 0` 的配置块: ```php $validConfigs = array_filter($configs, function($c) { return intval($c['template_id'] ?? 0) > 0; }); if (empty($validConfigs)) { return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置']; } Db::name('Goods')->where('id', $goodsId)->update([ 'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); ``` --- ## 结论 **幽灵 spec 的根因是后端未拒绝脏数据**,而非前端注入。`save_thing_end` 在模板验证失败时静默保留了无效的 config 块,导致 `vr_goods_config` 中的幽灵 spec 永远无法被清理。修复方向明确:任何 `template_id` 为空或指向不存在场馆的配置块,都必须被过滤或拒绝保存,并返回 `code: -401` 告知用户重新选择场馆。