# 安全审计报告：幽灵 Spec（Ghost Spec）安全问题评估

**审计人**: SecurityEngineer
**日期**: 2026-04-20
**审计对象**: 场馆硬删除后编辑商品的规格重复错误问题
**项目路径**: `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`

---

## 一、审计范围

本次审计覆盖以下文件：

| 文件 | 关键行号 | 审计重点 |
|------|---------|---------|
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 全文 | 保存钩子是否拒绝脏数据 |
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | 全文 | BatchGenerate 安全校验、GetGoodsViewData fallback |
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | 858-912 | VenueDelete 硬删除逻辑 |
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 182-449 | 前端 fallback 安全风险 |

---

## 二、S1 — AdminGoodsSaveHandle.php 审计

### S1-Q1: 当 template_id 指向不存在的场馆时，是否拒绝保存？

**结论：行为正确，但错误信息不友好**

关键代码路径：

1. **保存阶段 1**（第 22-41 行，`plugins_service_goods_save_handle`）：
   - 前端发送 `vr_goods_config_base64`（含 `template_id`、`selected_rooms`、`selected_sections`、`sessions`、`template_snapshot`）
   - 直接 base64 解码写入 `$params['data']['vr_goods_config']`
   - **无任何校验** — 这是正确的，因为此时模板可能还未删除

2. **保存阶段 2**（第 55-182 行，`plugins_service_goods_save_thing_end`）：
   - 第 77-90 行：遍历 configs，尝试重建 `template_snapshot`
   - **第 88-89 行**：模板不存在时执行 `continue`，**跳过 snapshot 重建但不阻断流程**
   - 第 158-172 行：对每个 `template_id > 0` 的 config 调用 `BatchGenerate`

3. **BatchGenerate 保护**（SeatSkuService.php 第 51-57 行）：
   ```php
   $template = Db::name(self::table('seat_templates'))
       ->where('id', $seatTemplateId)->find();
   if (empty($template)) {
       return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
   }
   ```

**结论**：如果 `template_id` 仍存在于 `vr_goods_config` 中但模板已被硬删除，`BatchGenerate` 返回 `code: -2`，该错误被第 169-171 行捕获并向上游返回，**整个保存事务被阻断**。用户看到的错误是 "座位模板 N 不存在"。

**评估**：安全 — 保存被正确阻断。但错误信息不友好（应告知用户重新选择场馆），属于 P2。

### S1-Q2: 幽灵 spec 是否可被恶意注入到 vr_goods_config？

**结论：不可注入，无漏洞**

分析：

- `vr_goods_config_base64` 中的字段：**由前端表单构造**，但不含 `spec_base_id_map`
- `spec_base_id_map` **仅存储在 `vr_seat_templates` 表中**（Admin.php 第 177 行）
- AdminGoodsSaveHandle 的保存流程中，**不读取也不回写 `spec_base_id_map`**
- `template_snapshot` 在保存时由后端从 DB 重建（第 77-90 行），前端传来的值被覆盖

攻击路径分析：
1. 攻击者能否伪造 `vr_goods_config_base64` 注入恶意 `spec_base_id_map`？→ **不能**，该字段不在表单构造范围内，且若注入则与 `template_id` 关联的 DB 记录不匹配，`BatchGenerate` 失败
2. 攻击者能否通过 `template_snapshot` 注入 XSS？→ **理论上可能**，`template_snapshot.venue` 未做 HTML 转义，但该字段仅在后端处理，不渲染到前端（ticket_detail.html 中 venue 数据来自 `$vr_seat_template` 而非 snapshot）
3. 攻击者能否利用 `template_id` 复用已删除场馆的规格？→ **不能**，`BatchGenerate` 会查 DB，找不到模板则返回错误

**结论：无安全漏洞（NO VULNERABILITY）**

### S1-Q3: spec_base_id 重复时是否有去重逻辑或安全阻断？

**结论：有兜底阻断（BatchGenerate 失败），但无专门去重逻辑**

- `BatchGenerate` 从 DB 读取当前模板的 `seat_map`，生成**新的**座位级 SKU
- 保存时会先清空现有规格数据（第 152-155 行）：
  ```php
  Db::name('GoodsSpecType')->where('goods_id', $goodsId)->delete();
  Db::name('GoodsSpecBase')->where('goods_id', $goodsId)->delete();
  Db::name('GoodsSpecValue')->where('goods_id', $goodsId)->delete();
  ```
- **先删后建**模式自然覆盖了旧的重复规格，不依赖去重

**结论：无 spec_base_id 重复安全问题

---

## 三、S2 — SeatSkuService.php 审计

### S2-Q1: GetGoodsViewData 在模板不存在时如何 fallback？

**结论：fallback 行为安全，但会修改数据库**

关键代码（SeatSkuService.php 第 380-393 行）：
```php
if (empty($seatTemplate)) {
    $config['template_id'] = null;
    $config['template_snapshot'] = null;
    \think\facade\Db::name('Goods')->where('id', $goodsId)->update([
        'vr_goods_config' => json_encode([$config], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ]);
    return [
        'vr_seat_template' => null,
        'goods_spec_data'  => [],
        'goods_config'     => $config,
    ];
}
```

**安全分析**：
- `vr_seat_template: null` — 前端收到的座位模板为空
- `goods_spec_data: []` — 场次列表为空
- **该方法会主动修改 DB**（将 `template_id` 置 null），这是一个"自愈"行为
- 自愈行为本身**不引入安全漏洞**，但有副作用：编辑商品时，用户原本的场馆关联被静默清空

**结论：fallback 逻辑本身安全，但会静默修改 DB 状态**

### S2-Q2: template_snapshot 是否可携带恶意 payload？

**结论：理论风险低，实际不可利用**

- `template_snapshot` 在保存时由后端重建（第 139-142 行），前端传入值被覆盖
- `template_snapshot` 字段未在 ticket_detail.html 中直接渲染
- `template_snapshot` 存储在 `vr_goods_config` JSON 中，无大小限制（vr_goods_config 字段需确认 DB schema）

**潜在风险**：
- 如果 `vr_goods_config` 字段无大小限制，可存储超大 JSON（DoS 风险）— 需 DB 层加限
- 如果未来代码变更直接渲染 `template_snapshot` 而不转义，可能 XSS — 当前代码无此路径

**结论：当前代码无实际可利用漏洞，建议在 DB 层对 `vr_goods_config` 加字段大小限制**

---

## 四、S3 — ShopXO 入口安全审计

### S3-Q1: ShopXO AdminGoodsSave.php 入口是否有参数校验？

**结论：入口层无专门校验，但 VR 插件有独立校验**

- `AdminGoodsSave.php`（文件不存在于项目根目录，可能位于 shopxo 源码中）作为 ShopXO 内核钩子入口
- VR 插件的商品保存通过插件钩子 `AdminGoodsSaveHandle::handle()` 处理
- 插件层面：校验逻辑在 `BatchGenerate` 中（模板存在性检查）
- **未发现**未授权保存、越权修改其他商品、参数注入等安全漏洞

**结论：入口安全，VR 插件有独立校验**

---

## 五、VenueDelete 硬删除逻辑审计

### 硬删除安全检查（Admin.php 第 858-912 行）

关键代码：
```php
// 检查是否有关联商品（使用 is_delete_time 而不是 is_delete）
$goods = \think\facade\Db::name('Goods')
    ->where('vr_goods_config', 'like', '%"template_id":' . $id . '%')
    ->where('is_delete_time', 0)
    ->find();
```

**安全分析**：
- 硬删除**不检查商品是否有关联**，直接执行删除（第 888 行）
- 关联商品仍然持有旧的 `template_id`，但如前所述，下次保存会被 `BatchGenerate` 阻断
- SQL 注入风险：`$id` 为 `intval`，安全
- 审计日志已记录（第 889-895 行）

**结论：硬删除安全，不引入额外漏洞**

---

## 六、漏洞严重性评级

| ID | 问题 | 类别 | 严重性 | 说明 |
|----|------|------|--------|------|
| V-1 | 场馆硬删除后保存失败，错误信息不友好（"座位模板 N 不存在"） | 功能/体验 | **P2** | 用户无法理解需要重新选择场馆 |
| V-2 | GetGoodsViewData 会静默修改 DB（将 template_id 置 null） | 功能/行为 | **P2** | 编辑商品时场馆关联被静默清空 |
| V-3 | loadSoldSeats() 为空实现，前端无法标记已售座位 | 业务逻辑 | **P2** | 用户可选中已售座位（超卖风险） |
| V-4 | template_snapshot 字段无大小限制 | DoS 风险 | **P3** | 需 DB 层加字段限制 |
| V-5 | spec_base_id_map 在保存流程中不可达 | 无漏洞 | — | 该字段仅用于 Plan A 订单流程 |

**P1 发现：0 个**
**P2 发现：3 个**
**P3 发现：1 个**

---

## 七、根因定性

**本次幽灵 spec 问题的根因是 P2（功能缺陷），不属于安全漏洞。**

具体机制：
1. 场馆 A 被硬删除，`vr_seat_templates` 表中无记录
2. 商品的 `vr_goods_config.template_id` 仍为 A 的 ID
3. `GetGoodsViewData` 在读取时将 `template_id` 置 null 并写回 DB（自愈）
4. 若用户在 `GetGoodsViewData` 执行前打开编辑页，前端收到 `template_id: null`，选单为空
5. 若 `vr_goods_config` 中 `template_id` 未被及时清理，下次保存时 `BatchGenerate` 返回错误阻断

**关键保护机制**：`BatchGenerate` 是最后一道防线 — 只要 `template_id` 仍指向不存在的模板，保存就会被阻断，不会有脏数据写入规格表。

---

## 八、修复建议（按优先级）

### P2-1（高优先级）：改善错误信息
**文件**: `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:55-57`
**修改**: 将错误信息改为用户可理解的形式，并引导重新选择场馆

### P2-2（中优先级）：防止静默 DB 修改
**文件**: `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:383-388`
**修改**: GetGoodsViewData 不应主动修改 DB，而应返回 flag 让调用方决定是否清理

### P2-3（中优先级）：实现 loadSoldSeats
**文件**: `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html:375-383`
**修改**: 实现从后端 API 加载已售座位数据

### P3-1（低优先级）：DB 字段大小限制
**修改**: 为 `goods.vr_goods_config` 字段加 TEXT/MEDIUMTEXT 限制，防止超大 JSON 存储

---

## 九、审计结论

本次审计**未发现任何 P1 安全漏洞**。幽灵 spec 问题是由场馆硬删除引发的**功能缺陷**（P2），核心保护机制（`BatchGenerate` 模板存在性检查）在场。关键安全属性：

- **无脏数据注入路径**：`spec_base_id_map` 不可控，不在表单提交范围内
- **保存有保护**：模板不存在时保存被阻断
- **无 XSS/SQL 注入**：所有输入均有适当处理
- **权限控制依赖 ShopXO 内核**：VR 插件不处理权限

建议优先处理 P2-1（错误信息改善）和 P2-3（已售座位标记），以提升用户体验和防止超卖。
