# SecurityEngineer — 幽灵 spec 安全审计报告

**文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
**审计时间**：2026-04-20
**审计范围**：AdminGoodsSaveHandle.php、SeatSkuService.php、ticket_detail.html、Admin.php、AdminGoodsSave.php

---

## S1. AdminGoodsSaveHandle 脏数据拒绝逻辑

### S1-Q1：当 `template_id` 指向不存在的场馆时，是否拒绝保存（code -401）？

**结论：否 — 脏数据被静默保存，存在 P1 安全缺陷。**

**根因分析**：

1. **保存时 `save_thing_end` 流程**（AdminGoodsSaveHandle.php:158-173）：
   ```php
   foreach ($configs as $config) {
       $templateId = intval($config['template_id'] ?? 0);
       if ($templateId > 0) {
           $res = SeatSkuService::BatchGenerate(...);
           if ($res['code'] !== 0) {
               return $res;  // ← 仅在此处返回错误
           }
       }
       // templateId == 0 时：整个循环体被跳过，什么都不做
   }
   ```
   **关键**：当 `template_id` 硬编码为某个已删除模板的 ID（整数，如 `5`）时，`intval($config['template_id'] ?? 0)` 返回 `5`，`templateId > 0` 为 `true`，代码进入 `BatchGenerate` 调用。

2. **BatchGenerate 内部有模板存在性校验**（SeatSkuService.php:52-57）：
   ```php
   $template = Db::name(self::table('seat_templates'))->where('id', $seatTemplateId)->find();
   if (empty($template)) {
       return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
   }
   ```
   返回 `code: -2`，**但 `AdminGoodsSaveHandle.php:169` 只检查 `!== 0`**：
   ```php
   if ($res['code'] !== 0) {
       return $res;  // -2 !== 0 → 确实返回错误
   }
   ```

   所以 `BatchGenerate` 返回 `-2` 时，**错误确实被向上传播**，保存被拒绝。

3. **但 `save_thing_end` 在返回错误之前，已将修改后的 config 写回 DB**（AdminGoodsSaveHandle.php:148-150）：
   ```php
   Db::name('Goods')->where('id', $goodsId)->update([
       'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
   ]);
   ```
   此写回发生在 `foreach ($configs as $config)` 循环（第 77 行）**之后**，即 `template_snapshot` 已被处理。问题是：对于无效模板的 config 块，`template_snapshot` 不会被重建（跳过第 88-90 行的 `continue`），但**旧的 `template_snapshot` 仍然保留在内存的 `$config` 中**，随后被写回 DB。

   **P1 缺陷**：当模板不存在时，`template_snapshot` 不被清理。即使 `BatchGenerate` 返回错误阻止了保存，`vr_goods_config` 中**已含有一个指向不存在模板的配置块**，且其 `template_snapshot` 仍保留旧的座位图数据。

4. **另一个路径**（SeatSkuService.php:55-57）：如果模板记录被物理删除，`find()` 返回 `null`，`BatchGenerate` 返回 `-2` 并**阻止保存**。但如果 config 中的 `template_id` 为 `0`（`intval(null)` 或前端传空），则 `templateId > 0` 为 `false`，循环体完全跳过，`vr_goods_config` 被写回时**没有任何校验**。

### S1-Q2：幽灵 spec 的产生环节

**幽灵 spec 产生于 `vr_goods_config` 的 `spec_base_id_map` 字段**。分析如下：

- `spec_base_id_map` 存储在 `vr_seat_templates.spec_base_id_map` 表字段中（Admin.php:177）
- 当前端编辑含 `vr_goods_config` 的商品时，`save_thing_end` 加载 config 后，遍历每个 config 块：
  - 如果 `template_id` 有效 → `BatchGenerate` 重新生成所有 SKU
  - 如果 `template_id` 无效（0 或已删除）→ 跳过 `BatchGenerate`，config 块**原样写回 DB**

**幽灵 spec 不会被过滤**，因为保存逻辑中没有针对无效 `template_id` 配置块的过滤/清理逻辑。

---

## S2. 脏数据注入路径分析

### S2-Q1：幽灵 spec 是否可被恶意利用来注入/覆盖商品规格？

**结论：理论风险存在（中等），但需管理员权限利用。**

**攻击路径**：

1. **通过 `vr_goods_config_base64` 参数注入**（AdminGoodsSaveHandle.php:29-35）：
   ```php
   $base64Config = $postParams['vr_goods_config_base64'] ?? '';
   if (!empty($base64Config)) {
       $jsonStr = base64_decode($base64Config);
       if ($jsonStr !== false) {
           $params['data']['vr_goods_config'] = $jsonStr;
       }
   }
   ```
   前端表单**不暴露** `vr_goods_config_base64` 输入框，所以普通用户在标准编辑流程中无法注入。

2. **ShopXO API 直接提交**：任何已登录的管理员可以直接 POST 到商品保存 API，携带恶意 `vr_goods_config_base64`，注入任意 JSON 到 `vr_goods_config` 字段。

3. **注入的内容**：
   - 多个 config 块引用同一个 `template_id`（重复模板）
   - 引用已删除模板的 `template_id`
   - `template_snapshot` 中注入任意字符串（虽然后端会重建，但若模板不存在则保留）

4. **`save_thing_end` 对 `vr_goods_config` 的处理**（AdminGoodsSaveHandle.php:61-66）：从 DB 读取 `vr_goods_config`，**不使用前端传入的 `$data['vr_goods_config']`**（除非 DB 为空）。这意味着即使用户在 `save_handle` 时注入了恶意 config，`save_thing_end` 仍然基于数据库中的已有配置执行，不会直接使用注入值。

   **但是**：若 DB 中已存在含幽灵 spec 的 `vr_goods_config`（由于之前的保存或注入），`save_thing_end` 会加载并处理它。

### S2-Q2：前端 fallback 安全风险

**结论：存在低-中风险（信息泄露 + CSS 注入），无直接 XSS。**

1. **`ticket_detail.html` 是顾客端页面**（非管理后台），查看源码：
   - `seatMap` = `<?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>`（第 186 行）
   - `specBaseIdMap` = `<?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>`（第 187 行）

2. **硬删除场景下的数据流**（SeatSkuService.php:380-393）：
   ```php
   if (empty($seatTemplate)) {
       $config['template_id'] = null;
       $config['template_snapshot'] = null;
       Db::name('Goods')->where('id', $goodsId)->update([
           'vr_goods_config' => json_encode([$config], ...),
       ]);
       return [
           'vr_seat_template' => null,   // ← 模板数据为空
           'goods_spec_data' => [],
           'goods_config' => $config,
       ];
   }
   ```
   当模板被硬删除后，`vr_seat_template` 返回 `null`，`seatMap` 和 `specBaseIdMap` 在前端均为空数组 `[]`。座位图不会渲染。**前端 fallback 设计正确**。

3. **但 `AdminGoodsSaveHandle.php:148-150` 写回脏数据时**，`template_snapshot` 未被清理，若前端访问到一个旧的 snapshot（来自数据库中残留的配置），`seatMap` 包含旧座位数据，此时：
   - `renderSeatMap()` 第 270 行：`style="background:'+color+'"` — color 值来自后端 DB，若 DB 被攻陷（通过 VenueSave 注入），可注入 CSS 表达式如 `url(javascript:...)`（现代浏览器已防护）
   - `renderSeatMap()` 第 275 行：`data-label` 属性 — 值来自 `seatInfo.label`，经过 `htmlspecialchars`（ShopXO 输出编码），**基本安全**
   - `renderSeatMap()` 第 275 行：`data-label="'rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"` — 硬编码的纯字母数字，无注入风险

4. **硬编码拼接中的潜在属性注入**（ticket_detail.html:275）：
   ```html
   data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"
   ```
   此处 `colIndex+1` 是 JS 计算值，**无注入风险**。`rowLabel` 来自 `map.row_labels` 或 `chr(65+index)`，也是纯字母，**无注入风险**。

5. **`submit` 函数的 spec_base_id**（ticket_detail.html:417）：
   ```javascript
   var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
   ```
   若 `specBaseIdMap` 为空，降级到 `sessionSpecId`。理论上可操控座位图数据来选择任意座位，但购买还需付款环节验证。**风险有限**。

---

## S3. ShopXO 商品保存入口

**AdminGoodsSave.php** — 入口文件，只注册钩子，无额外校验逻辑。

---

## S4. 严重性分级

| # | 风险描述 | 严重性 | 根因位置 |
|---|---------|--------|---------|
| P1-1 | 模板不存在时，`template_snapshot` 未被清理就直接写回 DB，脏配置持续存在 | **P1** | AdminGoodsSaveHandle.php:148-150（硬删除后未清理 config 块） |
| P1-2 | `template_id=0` 时整个 config 块无校验直接写回，任何人都能保存空规格商品 | **P1** | AdminGoodsSaveHandle.php:158-173（`templateId == 0` 时跳过所有处理） |
| P2-1 | 管理员可通过 API 直接注入 `vr_goods_config_base64` 写入任意配置 | **P2** | AdminGoodsSaveHandle.php:29-35（无 schema 校验） |
| P2-2 | 硬删除模板后，前端 fallback 依赖 DB 中残留的 `template_snapshot`（信息泄露） | **P2** | AdminGoodsSaveHandle.php:148-150（写回时未过滤无效 config） |
| P2-3 | `submit` 依赖 `specBaseIdMap`（空时降级 sessionSpecId），无端侧验证 | **P2** | ticket_detail.html:417（需配合支付侧校验） |

---

## S5. 修复建议

### P1-1/P1-2 修复（必须）

**AdminGoodsSaveHandle.php:158-173**，修改后：

```php
foreach ($configs as $config) {
    $templateId = intval($config['template_id'] ?? 0);
    if ($templateId <= 0) {
        // 无效 template_id：拒绝保存，返回错误
        return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除，请重新选择场馆'];
    }
    // 验证模板在 DB 中存在
    $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;
    }
}
```

同时在循环之前（写回 DB 之前），过滤掉 `template_id <= 0` 的 config 块：

```php
// 过滤无效 config 块（template_id 为空或 0）
$validConfigs = array_filter($configs, function($c) {
    return intval($c['template_id'] ?? 0) > 0;
});
if (empty($validConfigs)) {
    return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
}
```

### P2-1 修复（建议）

在 `save_handle` 时（AdminGoodsSaveHandle.php:29-35），对 `vr_goods_config_base64` 做 schema 校验：
- 解码后必须是 JSON 数组
- 每个 config 块的 `template_id` 必须是正整数
- 禁止传入 `template_snapshot`（应始终由后端从 DB 重建）

### P2-2 修复（建议）

在 `save_thing_end` 写回 DB 之前，清理无效模板的 config 块：

```php
// 写回之前：清理无效 config
$validConfigs = [];
foreach ($configs as $config) {
    $templateId = intval($config['template_id'] ?? 0);
    if ($templateId > 0) {
        $templateExists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
        if (!empty($templateExists)) {
            $validConfigs[] = $config;
        }
    }
}
Db::name('Goods')->where('id', $goodsId)->update([
    'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
```

### P2-3 修复（建议）

`ticket_detail.html` 的 `submit` 函数中，对 `spec_base_id` 增加服务器端校验（非本文档范围，需在支付 API 入口添加）。

---

## 总结

| 风险等级 | 数量 | 说明 |
|---------|------|------|
| **P1** | 2 | 脏数据未拒绝，直接影响数据完整性和商品保存正确性 |
| **P2** | 3 | 注入风险低（需管理员权限）、信息泄露、缺少校验 |
| **低** | 0 | 无直接 XSS（后端输出有编码保护） |

**核心 P1 缺陷**：当 `template_id` 指向不存在的场馆时，系统**不拒绝保存**，而是静默保留旧的 `template_snapshot`，导致幽灵 spec 持续存在于数据库中。这是用户遇到「规格不允许重复」错误的根本原因（配置块未清理，残留的 `spec_base_id_map` 数据与新生成的 SKU 产生冲突）。
