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