134 lines
5.3 KiB
Markdown
134 lines
5.3 KiB
Markdown
|
|
# 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` 告知用户重新选择场馆。
|