vr-shopxo-plugin/reviews/SecurityEngineer-on-GhostSp...

246 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 产生冲突)。