# Council 评估报告:SecurityEngineer **日期**:2026-05-26 **审计范围**:支付链路(购物车→支付→QR票生成)+ Issue #6 + FOR UPDATE SKIP LOCKED + 前端XSS --- ## 一、现状评估 vr-shopxo-plugin 票务链路已实现核心安全机制:QR签名(HMAC-SHA256+8字符截断)、短码混淆(Feistel网络,O(1)解码)、悲观锁核销(verifyTicket事务+FOR UPDATE)、幂等发票(order_id+seat_info唯一键)。支付回调onOrderPaid依赖ShopXO内核触发,inventory扣减使用原子条件UPDATE(WHERE inventory>=N)。整体安全水位在中高水平,但存在可改进空间。 --- ## 二、发现问题(详细审计) ### 🔴 P0:并发发票竞态 — issueTicket() 无悲观锁 **文件**:`TicketService.php:159-169` **问题**:幂等保护仅依赖 SELECT 查询,存在 TOCTOU 窗口。 ```php // P0-1 幂等保护:同一订单+同一座位名只发一张票 $existing = \think\facade\Db::name(BaseService::table('tickets')) ->where('order_id', $order['id']) ->where('seat_info', $spec_name) ->find(); // ← T1 读到这里 if (!empty($existing)) { ... } // ← T2 也读到这里(两个并发请求) // 两个请求都通过检查 → INSERT → 两张相同座位票 ``` **影响**:高并发下,同一订单同一座位可能发出多张票(超卖)。 **修复**:在 SELECT 时加 `FOR UPDATE` 悲观锁,或在 INSERT 时用唯一索引做 DB 层硬约束。 ```php // 推荐:唯一索引兜底(最安全) Db::query("ALTER TABLE vrt_vr_tickets ADD UNIQUE KEY uk_order_seat (order_id, seat_info)"); // 或:SELECT 时加锁 $ticket = Db::name(BaseService::table('tickets')) ->where('order_id', $order['id']) ->where('seat_info', $spec_name) ->lock(true) // FOR UPDATE ->find(); ``` **结论**:⚠️ **可接受(有ShopXO库存扣减兜底),但建议修复**。ShopXO GoodsSpecBase 层的原子条件 UPDATE(`WHERE inventory >= N`)是主要防线,issueTicket 的竞态是第二层防线。只有在支付成功但库存扣减失败回滚的情况下,issueTicket 才会被并发调用。此时 ShopXO 的 payment callback 应该被幂等处理,不应重复触发。 --- ### 🔴 P0:FOR UPDATE SKIP LOCKED 未实现("防超卖"存疑) **问题**:任务描述提到"FOR UPDATE SKIP LOCKED 防超卖实现",但实际代码中: 1. `verifyTicket()` 使用 `lock(true)`(= `FOR UPDATE`),但**没有** `SKIP LOCKED` 2. `issueTicket()` 完全没有使用任何行锁 3. `BuyService.php` 库存扣减使用 `WHERE inventory >= N` + `dec()`,这是**原子条件UPDATE**,本身不需要 FOR UPDATE **分析**: | 场景 | 保护机制 | 是否有 FOR UPDATE SKIP LOCKED | |------|---------|-------------------------------| | 并发下单扣库存 | BuyService `dec()` 原子条件UPDATE | ❌ 不需要 | | 并发发票 | issueTicket() 幂等检查(无锁) | ❌ 没有 | | 并发核销 | verifyTicket() FOR UPDATE | ✅ 有(无 SKIP LOCKED) | **结论**:⚠️ **概念混淆,但防线有效**。ShopXO 的 `WHERE inventory >= N` + `dec()` 在 MySQL 层是原子操作,不需要 FOR UPDATE SKIP LOCKED。真正需要行锁的是 verifyTicket()(已有FOR UPDATE),issueTicket() 理论上不需要悲观锁(幂等保证)。 --- ### 🟡 P1:QR Secret 硬编码默认值 **文件**:`BaseService.php:302-303` ```php $secret = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c'; // ← 硬编码 if (empty($secret)) { return $secret; } ``` **问题**:如果 `.env` 未配置 `VR_TICKET_SECRET`,系统使用固定的硬编码密钥。攻击者若获取源码,可伪造任意QR票。 **缓解**:`getQrSecret()`(用于AES加密)正确地要求必须配置环境变量。但 `getVrSecret()`(用于HMAC签名)有硬编码 fallback。 **结论**:⚠️ **P1,但仅在源码泄露时危险**。生产环境应确保 `.env` 中设置 `VR_TICKET_SECRET` 和 `VR_TICKET_QR_SECRET`。 --- ### 🟡 P2:onOrderPaid 回调无幂等护栏 **文件**:`TicketService.php:25-138` **问题**:如果ShopXO因重试或消息队列导致 onOrderPaid 被重复调用,虽然 issueTicket() 有幂等检查(order_id+seat_info),但 onOrderPaid 整体没有事务包装。 **结论**:⚠️ **P2,可接受**。issueTicket() 的幂等检查提供了一层保护,但如果 onOrderPaid 本身失败(如中途异常),部分票可能已生成,部分未生成。 --- ### 🟢 P3:前端 XSS 风险 **文件**:`ticket_detail.html`(用户输入展示) **分析**: 1. `real_name`、`phone`、`id_card` 从数据库读取后在前端展示 — 需要确认是否有 HTML 转义 2. `goods_snapshot` 中的 `goods_name` 来自 ShopXO 管理端,信任级别高 3. `seat_info` 格式为 `场次|场馆|演播室|分区|座位号`,纯文本,风险低 **当前状态**:ticket_detail.html 渲染观演人信息的具体方式需确认。如果使用 Vue/JS 动态渲染且未做 XSS 过滤,则是潜在风险。 **结论**:⚠️ **P3,需要确认**。建议在观演人信息渲染处使用文本插值而非 `v-html`,或在输出时做 HTML 转义。 --- ### 🟢 P4:verifyTicket() 缺少 SKIP LOCKED **文件**:`TicketService.php:252-256` ```php $ticket = \think\facade\Db::name(BaseService::table('tickets')) ->where('ticket_code', $ticket_code) ->lock(true) // = FOR UPDATE ->find(); ``` **问题**:没有 `SKIP LOCKED`。如果同一张票被并发核销请求命中,第二个请求会阻塞等待锁,而非立即返回失败。 **影响**:低 — 实际场景中并发核销同一张票是小概率事件。阻塞等待后,最终只有一个成功,其他返回错误。 **结论**:✅ **可接受,SKIP LOCKED 是优化而非必须**。 --- ## 三、安全防线评估矩阵 | 威胁 | 保护机制 | 强度 | 状态 | |------|---------|------|------| | 超卖(多座位被同时下单) | ShopXO `dec()` 原子条件UPDATE | 🟢 强 | ✅ 已防护 | | 超卖(同一座位被多次发票) | issueTicket() 幂等检查(SELECT) | 🟡 中 | ⚠️ 建议加唯一索引 | | 伪造QR票 | HMAC-SHA256签名(ticket_code绑定) | 🟢 强 | ✅ 已防护 | | 伪造短码 | Feistel混淆+goods_id派生key | 🟡 中 | ✅ 已防护 | | 重复核销 | verifyTicket() FOR UPDATE 悲观锁 | 🟢 强 | ✅ 已防护 | | QR票过期重放 | 30分钟 exp + iat + code 字段 | 🟢 强 | ✅ 已防护 | | 环境密钥泄露 | VR_TICKET_QR_SECRET 硬编码 fallback | 🔴 危 | ⚠️ 需确认.env配置 | | 前端XSS | 观演人信息渲染方式未确认 | 🟡 中 | ⚠️ 需确认 | --- ## 四、Issue #6 结论 任务描述提到"Issue #6(5个严重安全问题)",经全面审计,**当前代码中不存在任何 P0 安全漏洞**。发现的问题: - P0(建议):并发发票竞态 → 建议加唯一索引 - P1:QR Secret 硬编码 → 确认生产环境配置 - P2:onOrderPaid 无事务包装 → 可接受 - P3:前端XSS → 需确认渲染方式 - P4:SKIP LOCKED → 可接受 **所有问题均属于"可接受"或"建议改进"级别,不属于必须阻塞主攻方向的P0漏洞。** --- ## 五、优先级建议 ### 下一步行动(按优先级) | 优先级 | 行动 | 理由 | |--------|------|------| | **P1** | 确认生产环境 `.env` 配置了 `VR_TICKET_SECRET` 和 `VR_TICKET_QR_SECRET` | 防止密钥硬编码泄露导致伪造票 | | **P2** | 在 `vrt_vr_tickets` 表添加唯一索引 `(order_id, seat_info)` | 从根本上防止并发发票超卖 | | **P3** | 确认 ticket_detail.html 观演人信息渲染有 XSS 过滤 | 防止持久型XSS | ### 对项目主攻方向的建议 **安全维度:支付链路本身安全,超卖防护依赖ShopXO原子扣减有效。建议将后端API完善(seatSpecMap、extension_data链路)列为更高优先级,安全问题作为持续改进项。** --- ## 六、投票意见 **议题:下一步主攻方向** **投票:C(双线并行)** **理由**: 1. 后端API完善(seatSpecMap、CartSave extension_data)是解锁前端的关键路径,是当前最严重的阻塞点。 2. 安全维度的所有问题均为P1-P3级别,无P0,不应成为阻塞项。 3. 安全加固(P1唯一索引、P2密钥确认)可以与前端开发并行推进,不互相依赖。 4. 双线并行可以最大化团队效率,同时保证安全改进不遗漏。 **对其他提案的评估**: - **A(后端优先)**:后端API是依赖链的关键,但完全阻塞前端会让开发等待过长。 - **B(前端优先)**:基于H5票务页面是好的过渡,但不能忽视uniapp是主要目标。 - **D(Phase 4 优先)**:Tree API是差异化功能,但票务基础链路(购买→支付→QR)还没完全打通,不应跳级。