diff --git a/plan.md b/plan.md index 071c915..6cfeab9 100644 --- a/plan.md +++ b/plan.md @@ -7,10 +7,16 @@ ======= # Council Plan — vr-shopxo-plugin 代码审议 +<<<<<<< HEAD > Round 1 — 2026-04-15 > Branch: council/FrontendDev → main > 状态:**Draft Phase** >>>>>>> council/FrontendDev +======= +> Round 2 — 2026-04-15 +> Branch: council/BackendArchitect → main +> 状态:**Review Phase → Finalize** +>>>>>>> council/BackendArchitect --- @@ -51,6 +57,7 @@ ## Task Checklist +<<<<<<< HEAD <<<<<<< HEAD - [x] 1. 插件架构审计(EventListener.php / plugin.json) - [x] 2. 票务核心审计(TicketService.php / BaseService.php) @@ -84,11 +91,21 @@ - [x] R5: 安全性综合审计(注入/XSS/重放/QR伪造) - [x] R6: 汇总评审报告 (reviews/code-review-FrontendDev.md) >>>>>>> council/FrontendDev +======= +- [x] A1: 读取并分析 plugin.json + EventListener.php +- [x] A2: 读取并分析 service/TicketService.php + BaseService.php +- [x] A3: 读取并分析 view/goods/ticket_detail.html +- [x] A4: 读取并分析 database/migrations/ 所有文件 + admin controllers +- [x] B1: 安全性专项审计(SQL注入/XSS/重放/QR伪造) +- [x] C1: 输出 reviews/code-review-BackendArchitect.md(500字+) +- [x] D1: 合并 plan.md + review 报告到 main +>>>>>>> council/BackendArchitect --- ## Phase Breakdown +<<<<<<< HEAD | Phase | 内容 | 状态 | |---|---|---| <<<<<<< HEAD @@ -111,6 +128,13 @@ | 🟢 轻微 | 3 | 已选座位 UI 无状态管理 / 观演人表单无校验 / 座位映射数据泄露 | | 💡 建议 | 4 | 座位字符集ASCII限制 / 座位数无上限 / spec_base_id缺索引 / 地图JSON无长度限制 | >>>>>>> council/FrontendDev +======= +| Phase | 内容 | Owner | 状态 | +|---|---|---|---| +| **Draft** | 读取代码文件,执行分类审议 | council/BackendArchitect | ✅ Done | +| **Review** | 输出评审报告到 reviews/ | council/BackendArchitect | ✅ Done | +| **Finalize** | 合并到 main,投票 | council/All | ⏳ Pending | +>>>>>>> council/BackendArchitect --- @@ -119,6 +143,7 @@ | Task | Owner | Status | |---|---|---| <<<<<<< HEAD +<<<<<<< HEAD | 插件架构审计 | council/SecurityEngineer | `[Done]` | | 票务核心审计 | council/SecurityEngineer | `[Done]` | | 前端票务页审计 | council/SecurityEngineer | `[Done]` | @@ -145,3 +170,42 @@ **[CONSENSUS: NO]** — Round 1 规划完成,待执行审议 >>>>>>> council/FrontendDev +======= +| A1: plugin.json + EventListener.php 分析 | council/BackendArchitect | ✅ Done | +| A2: TicketService.php + BaseService.php 分析 | council/BackendArchitect | ✅ Done | +| A3: ticket_detail.html 分析 | council/BackendArchitect | ✅ Done | +| A4: database migrations + admin controllers | council/BackendArchitect | ✅ Done | +| B1: 安全性专项审计 | council/BackendArchitect | ✅ Done | +| C1: 输出评审报告 | council/BackendArchitect | ✅ Done | +| D1: 合并到 main | council/BackendArchitect | ⏳ in_progress | + +--- + +## 发现汇总(BackendArchitect Round 2 终稿) + +| # | 严重度 | 类别 | 文件 | 问题 | +|---|---|---|---|---| +| 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()` 二次写入时序问题 | +| 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` 缺少独立索引 | + +**[CONSENSUS: YES]** — Round 2 执行完毕,评审报告已就绪,等待合并 +>>>>>>> council/BackendArchitect diff --git a/reviews/code-review-BackendArchitect.md b/reviews/code-review-BackendArchitect.md index 017984c..d3f3e62 100644 --- a/reviews/code-review-BackendArchitect.md +++ b/reviews/code-review-BackendArchitect.md @@ -1,51 +1,59 @@ -# vr-shopxo-plugin 代码审议报告 +# vr-shopxo-plugin 代码深度审议报告(Round 2 终稿) > 审议人:BackendArchitect > 日期:2026-04-15 -> 审议范围:vr_ticket 插件核心代码(EventListener.php、TicketService.php、BaseService.php、ticket_detail.html) -> 视角:Backend Architect / PHP / 数据库 / 架构完整性 +> 审议范围: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 plugin.json 生命周期钩子缺失 ⚠️ 严重 +### 1.1 Enable/Disable 生命周期钩子完全缺失 ⚠️ 严重 -**问题**:plugin.json 声明了 `hooks` 数组包含两个 hook,但文件中缺少关键的 **Install/Uninstall/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" + "plugins_service_order_delete_success" // 声明了但无处理函数 ] ``` -ShopXO 插件标准要求实现以下函数: -- `vr_ticket_install()` — ✅ 存在 -- `vr_ticket_uninstall()` — ✅ 存在(但为空实现) -- `vr_ticket_enable()` — ❌ 缺失 -- `vr_ticket_disable()` — ❌ 缺失 +`EventListener.php` 中没有 `vr_ticket_order_delete()` 或类似函数。订单删除后,`vr_tickets` 表中的票记录仍保留(状态不变),导致: +- 已删除订单的票仍可被核销入场 +- `vr_tickets.order_id` 成为孤儿记录,关联查询失效 -**影响**:插件启用/停用时无法执行必要的初始化或清理操作,可能导致菜单重复注册或权限残留。 +### 1.3 ALTER TABLE 兼容性判断逻辑错误 ⚠️ 中等 -**修复建议**: -```php -function vr_ticket_enable() -{ - // 注册菜单/权限 - return true; -} - -function vr_ticket_disable() -{ - // 清理菜单/权限状态 - return true; -} -``` - -### 1.2 ALTER TABLE 缺少兼容性检查 ⚠️ 中等 - -EventListener.php 第 100-103 行: +**文件:** `EventListener.php:100-103` ```php $cols = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'"); @@ -54,53 +62,131 @@ if (empty($cols)) { } ``` -`SHOW COLUMNS` 返回的是结果集(resource 或 PDOStatement),不是数组,直接 `empty($cols)` 判断无效。应使用 `$cols->rowCount()` 或重新查询。 +`$db->query()` 在 ShopXO 中返回的是结果集对象(PDOStatement 或 mysqli_result),而非布尔值。`empty($cols)` 对对象始终返回 `false`,**导致条件永不成立,`ALTER TABLE` 永远不会被执行**。也就是说 `item_type` 字段实际上从未被添加到 goods 表,`isTicketGoods()` 的第二条件 `($goods['item_type'] ?? '') === 'ticket'` 永远无法触发。 -### 1.3 Uninstall 空实现 ⚠️ 建议 +实际应改为: +```php +$col_exists = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'")->rowCount() > 0; +if (!$col_exists) { ... } +``` -`vr_ticket_uninstall()` 返回 `true` 但不做任何清理。虽然保留数据是合理的设计决策,但应在代码注释中明确标注「有意保留数据」,而非看起来像是未完成的占位符。 +### 1.4 Upgrade 框架缺失 ⚠️ 建议 -### 1.4 Upgrade 空实现 ⚠️ 建议 +`vr_ticket_upgrade($old_version)` 为空实现。当前版本号 `1.0.0` 写死在 plugin.json,若未来需要: +- 新增 `refund_status` 字段 +- 修改 QR payload 结构 +- 拆分 `seat_map` JSON schema -`vr_ticket_upgrade($old_version)` 是空实现,未来版本升级时可能遗漏数据迁移。应在首次发布时就设计好版本号比对框架。 +没有任何迁移路径。建议建立 `vr_plugin_versions` 表或迁移脚本目录。 --- ## 二、票务核心(TicketService.php / BaseService.php) -### 2.1 onOrderPaid() 重复发放风险 — 并发漏洞 ⚠️ 严重 +### 2.1 `onOrderPaid()` 无幂等性保护,可导致重复发票 ⚠️ 严重 -`TicketService.php` 第 23-68 行,**没有**任何幂等性保护。如果以下情况发生: +**文件:** `TicketService.php:23-68` -1. ShopXO 支付回调在短时间内触发两次(网络重试) -2. 多实例部署下两个进程同时处理同一订单 - -**结果**:同一订单商品生成两张票(inventory=1 的票出现多张)。 - -代码中没有任何以下保护机制: -- 事务(Transaction) -- 悲观锁(SELECT ... FOR UPDATE) -- 幂等键(已发放标志检查) - -**修复建议**:在 `onOrderPaid()` 开头增加幂等检查: ```php -$existing = \Db::name(BaseService::table('tickets')) - ->where('order_id', $order_id) - ->find(); -if (!empty($existing)) { - BaseService::log('onOrderPaid: already issued', ['order_id' => $order_id], 'info'); - return true; // 已发放,跳过 +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); + } } ``` -### 2.2 issueTicket() 二次写入时序问题 ⚠️ 中等 +ShopXO 的 `plugins_service_order_pay_success_handle_end` 钩子通过 HTTP 请求触发。在以下场景中,同一订单会触发多次 `onOrderPaid`: -第 96-126 行: +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 -$ticket_id = \Db::name(...)->insertGetId([...]); // qr_data 的 id=0 +// Step 1: 读取票状态 +$ticket = \Db::name(BaseService::table('tickets')) + ->where('ticket_code', $ticket_code) + ->find(); -// 更新 QR 数据中的 ticket_id +// 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); @@ -108,42 +194,74 @@ if ($ticket_id > 0) { } ``` -在两步之间,如果系统读取了 ticket,会得到 `id=0` 的 QR 数据(虽然 `decryptQrData` 会成功解密,但数据内容不完整)。 +在两次写入之间,数据库中存储的是 `id=0` 的无效 QR payload。如果核销接口在这段时间被调用(极端低概率但存在),`decryptQrData` 会返回 `id=0` 的数据,与真实票记录产生不一致。 -**修复建议**:使用数据库事务包裹,或在插入前生成 UUID 作为内部关联码,而非依赖插入后的自增 ID。 - -### 2.3 verifyTicket() 核销状态竞态条件 ⚠️ 严重 - -第 138-196 行,`verifyTicket` 使用「查询-判断-更新」三步模式: +**根本原因**:依赖插入后自增 ID,而非使用预生成的 UUID 作为 QR payload 的主键标识。 +**修复方案**:在调用 `insertGetId` 前就生成内部关联 UUID: ```php -$ticket = \Db::name('tickets')->where('ticket_code', $ticket_code)->find(); -if ($ticket['verify_status'] == 1) { return ... } -\Db::name('tickets')->where('id', $ticket['id'])->update(['verify_status' => 1, ...]); +$internal_ref = BaseService::generateUuid(); // 预生成 +$qr_payload['ref'] = $internal_ref; +$ticket_id = \Db::name(...)->insertGetId([...]); +// 无需二次更新 ``` -在并发场景下,两个核销员可能同时通过状态检查,导致: -- 同一张票被核销两次(记录写入两次) -- 核销员 A 成功更新后,核销员 B 的 update 仍会执行(覆盖 verifier_id) +### 2.4 `getQrCodeUrl()` 明文暴露票码 ⚠️ 中等 + +**文件:** `TicketService.php:220-228` -**修复建议**:使用带条件的原子更新: ```php -$affected = \Db::name(BaseService::table('tickets')) - ->where('id', $ticket['id']) - ->where('verify_status', 0) // 原子条件 - ->update(['verify_status' => 1, 'verify_time' => $now, ...]); -if ($affected === 0) { - return ['code' => -2, 'msg' => '该票已核销']; +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) ... } ``` -### 2.4 QR Secret 密钥管理问题 ⚠️ 严重 +QR 码内容仅为 `base64(json_encode({type, code}))`,**无需任何解密即可读出 ticket_code**。这意味着: -BaseService.php 第 98-107 行: +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 -private static function getQrSecret() -{ +$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-GCM(AES-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; @@ -152,201 +270,321 @@ private static function getQrSecret() } ``` -问题: -1. **硬编码默认值**:`'shopxo_default_secret_change_me'` 若被用于生产环境,QR 加密等于明文 -2. **密钥与 ShopXO 共享**:`app_key` 可能被用于多个目的,增加密钥泄露面 -3. **无密钥强度验证**:未检查密钥长度是否满足 AES-256 要求 +三个问题: +1. `env()` 在 PHP 中取值依赖 `getenv()`,ShopXO 环境变量机制未必与标准 Laravel 一致 +2. `'shopxo_default_secret_change_me'` 是明确的已知默认值,若环境变量读取失败(配置错误),系统以不安全密钥运行 +3. 未验证密钥长度是否满足 AES-256 要求(32 字节) -**修复建议**: +**修复方案:** 环境变量缺失时主动抛出异常,而非静默回退: ```php -private static function getQrSecret() -{ +private static function getQrSecret() { $secret = env('VR_TICKET_QR_SECRET', ''); - if (empty($secret) || strlen($secret) < 32) { - throw new \Exception('VR票务 QR 加密密钥未配置或长度不足(需要32字符)'); + 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; } ``` -### 2.5 AES 加密模式无 HMAC 防篡改 ⚠️ 中等 - -`encryptQrData()` 使用 `AES-256-CBC` + `OPENSSL_RAW_DATA`,但没有对密文做 HMAC 签名。在某些 padding oracle 攻击场景下,CBC 模式可能被利用。即使当前风险较低,缺乏完整性验证是设计缺陷。 - -**修复建议**:在加密后增加 HMAC: -```php -$mac = hash_hmac('sha256', $iv . $encrypted, $secret); -return base64_encode($iv . $encrypted . $mac); -``` - -### 2.6 getQrCodeUrl() 明文暴露票码 ⚠️ 中等 - -第 220-228 行: - -```php -public static function getQrCodeUrl($ticket_code) -{ - $content = base64_encode(json_encode([ - 'type' => 'vr_ticket', - 'code' => $ticket_code, // ⚠️ 未经加密的票码直接暴露 - ])); - return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) ... -} -``` - -QR 码扫描后,任何人都能从 URL 中提取 `ticket_code`(只需 `base64_decode`),无需破解加密。这使得: -1. **票码可枚举**:攻击者可遍历 1-N 的 UUID 尝试核销 -2. **隐私泄露**:QR 码本身不需要包含明文票码 - -**修复建议**:QR URL 只传加密后的 payload,不包含明文 ticket_code: -```php -$qr_data = BaseService::encryptQrData([ - 'code' => $ticket_code, - // 不需要 type 明文 -]); -return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($qr_data); -``` - --- ## 三、前端票务详情页(ticket_detail.html) -### 3.1 XSS 漏洞 — `|raw` 过滤器 ⚠️ 严重 +### 3.1 `{$goods.simple_desc|raw}` 直接输出 HTML 导致 XSS ⚠️ 严重 + +**文件:** `ticket_detail.html:125` -第 125 行: ```html
{$goods.simple_desc|default=''|raw}
``` -`simple_desc` 字段内容由商家后台输入,直接用 `|raw` 输出到 HTML,等于允许任意 XSS 注入。攻击者只需在商品副标题输入 `` 即可窃取用户 cookie。 +`simple_desc` 来自商品表字段,由商家后台输入。`{|raw}` 完全绕过 ThinkPHP 的自动 HTML 转义。攻击者在商品副标题输入: +```html + +``` +即可窃取任意访问商品页用户的 session cookie。 -**修复建议**: -- 移除 `|raw`,使用 `|htmlspecialchars` -- 或在后端输出前统一做 XSS 过滤 +### 3.2 `{$goods.content|raw}` 商品详情富文本 XSS ⚠️ 严重 -### 3.2 购票数据无服务端验证 ⚠️ 严重 +**文件:** `ticket_detail.html:164` -第 384-422 行 `submit()` 函数: - -```javascript -var checkoutUrl = this.requestUrl + '?s=index/buy/index' + - '&goods_params=' + encodeURIComponent(goodsParams); -location.href = checkoutUrl; +```html +
{$goods.content|raw}
``` -购票参数(座位、票价、数量)全部由 JavaScript 计算后拼接 URL,**没有任何服务端验证**。攻击者可: -1. 篡改 `price` 为 0.01,购买任意座位 -2. 绕过前端座位数量限制,超购座位 -3. 伪造 `extension_data` 中的 `seat_info` +`goods.content` 通常为商家编辑的富文本(包含图片、样式),`{|raw}` 等同于信任所有内容。虽然这是 ShopXO 标准做法,但 VR 票务插件独立使用此模板,放大了风险面。若 ShopXO 后台的内容过滤器存在绕过,此处直接受影响。 -**修复建议**: -- 在 `submit()` 中改为 POST 请求 -- 服务端在 `plugins_service_buy_order_create_end` hook 中**重新计算价格**,不以客户端参数为准 -- 添加签名验证(HMAC of goods_params + secret) +### 3.3 购票参数全由客户端计算,无服务端验签 ⚠️ 严重 -### 3.3 座位图渲染 XSS 风险 — 动态插入 HTML ⚠️ 中等 +**文件:** `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` -第 271-276 行: ```javascript rowsHtml += '
'; ``` -`seatInfo.classes` 直接拼入 HTML class 属性。虽然 class 属性本身 XSS 风险低于 innerHTML,但如果 `classes` 值包含引号或特殊字符(如 `a" onclick="evil()"`),可破坏属性边界。 - -**修复建议**:对 `seatInfo.classes` 做属性值转义: -```javascript -var safeClasses = (seatInfo.classes || '').replace(/"/g, '"'); -``` - -### 3.4 观演人表单无服务端验证 ⚠️ 中等 - -`attendeeData`(第 395-407 行)收集的姓名、手机、身份证直接存入订单扩展数据,没有任何: -- 格式验证(手机号正则、身份证号校验) -- 长度限制 -- 恶意内容过滤(`