council(security): SecurityEngineer - add missing VenueList methods + security audit

Security findings:
- SQL injection: LOW (query builder + parameter binding)
- XSS: LOW (ThinkPHP auto-escape, no |raw detected)
- Path traversal: LOW (all view paths hardcoded)
- CSRF: MEDIUM (ShopXO framework-level gap, out of scope for plugin)

Critical fix: admin/Admin.php was missing VenueList(), VenueSave(),
VenueDelete() — sidebar URL "/plugins/vr_ticket/admin/venueList" would
return 500 error. Added all three methods with v3.0 seat_map support.

P1 garbled name: documented DB fix SQL for shx_plugins + vrt_power tables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor/vr-ticket-20260416
Council 2026-04-16 08:53:41 +08:00
parent 000a0990e7
commit 35c10a7f66
3 changed files with 459 additions and 1 deletions

39
plan.md
View File

@ -128,3 +128,42 @@ app/plugins/vr_ticket/
| P1-T4 | [Pending] | 需实际访问 URL 截图验证 |
| P2-T1 | [Pending] | 数据库编码检查(需 DB 访问)|
| P2-T2 | [Pending] | 数据库修复(如需要)|
---
## SecurityEngineer Round 5 补充
### 关键发现VenueList() 方法缺失Critical Bug
plugin.json sidebar URL `/plugins/vr_ticket/admin/venueList` 链接到 `VenueList()` 方法,但 admin/Admin.php 中该方法不存在 → 点击"场馆配置"菜单会导致 500 错误。
**已修复**:在 admin/Admin.php 中添加:
- `VenueList()` — 场馆列表(含 v3.0 seat_map 解析)
- `VenueSave()` — 场馆创建/编辑(含 v3.0 JSON 构建和验证)
- `VenueDelete()` — 场馆软删除(含审计日志)
- `countSeatsV2()` — v2 格式(数组)座位计数辅助方法
### 安全审计结论
| 安全项 | 风险等级 | 结论 |
|--------|----------|------|
| SQL 注入 | LOW | 所有查询使用 ThinkPHP query builder + 参数绑定 |
| XSS | LOW | ThinkPHP 模板引擎自动转义,无 `\|raw` 输出 |
| 路径遍历 | LOW | 所有视图路径为硬编码方法名,无用户输入 |
| CSRF | MEDIUM | ShopXO 框架级缺失,插件层面无法单独修复 |
| 数据编码P1乱码| LOW | DB latin1 存储导致乱码,非安全漏洞 |
### P1 乱码 DB 修复 SQL
```sql
-- 1. 诊断
SELECT id, name, title, LENGTH(name), HEX(name) FROM shx_plugins WHERE name LIKE '%vr%';
-- 2. 修复 plugins 表
UPDATE shx_plugins SET name = 'vr_ticket', title = 'VR票务' WHERE name = 'vr_ticket';
-- 3. 修复 vrt_power 表(如果存在乱码)
SELECT id, name, LENGTH(name), HEX(name) FROM vrt_power WHERE name LIKE '%票%';
UPDATE vrt_power SET name = 'VR票务' WHERE HEX(name) LIKE '%E7A58A%';
```
详细安全分析见:`reviews/SecurityEngineer-round5-review.md`

View File

@ -0,0 +1,194 @@
# SecurityEngineer Round 5 Review — vr_ticket Phase 2 Security Analysis
> Date: 2026-04-16 | Reviewer: SecurityEngineer | Scope: admin/Admin.php + plugin.json + Vrticket.php
---
## Executive Summary
Two root causes confirmed from previous rounds:
- **P1 (乱码)**: DB field double-encoding (UTF-8 bytes stored as Latin1)
- **P2 (路由)**: Controller in wrong subdirectory; missing `VenueList()` method
**New finding this round**: Critical missing `VenueList()` method blocks sidebar navigation.
---
## P1 — Sidebar Garbled Text (`VR票务`)
### Root Cause
The garbled `VR票务` = UTF-8 bytes decoded as Latin1:
- `票务` UTF-8 bytes: `E7 A5 8E E5 8A B1`
- Interpreted as Latin1 → `ç ¥¨ 务`
- **Source**: `plugins` DB table's `name`/`title` field (not `plugin.json` — that file is correct UTF-8)
- The sidebar menu reads plugin name from the database, not from `plugin.json`
### Fix Required (DB operation)
```sql
-- 诊断查询
SELECT id, name, title, LENGTH(name), HEX(name) FROM shx_plugins WHERE name LIKE '%票%';
-- 修复:将 latin1 乱码字段更新为正确的 UTF-8 中文
UPDATE shx_plugins
SET name = 'vr_ticket', title = 'VR票务'
WHERE name = 'vr_ticket';
-- 同时检查 vrt_power 表
SELECT id, name, LENGTH(name), HEX(name) FROM vrt_power WHERE name LIKE '%票%';
-- 如果有乱码:
UPDATE vrt_power SET name = 'VR票务' WHERE id = <id>;
```
### Security Note
This is a **data corruption** issue, not a security vulnerability. No injection risk here.
---
## P2 — Routing Failure
### Root Cause
1. **Old controller** `admin/controller/Venue.php` uses namespace `app\plugins\vr_ticket\admin\controller\Venue`
2. ShopXO `PluginsService::PluginsControlCall` loads class `app\plugins\vr_ticket\admin\Venue` (without `controller` subdir)
3. This resolves to `app/plugins/vr_ticket/admin/Venue.php` — **file doesn't exist**
4. The new `admin/Admin.php` has the correct PSR-4 path but was **missing `VenueList()` and `VenueSave()` methods**
### Fix Applied
Added to `admin/Admin.php`:
- `VenueList()` — delegates to `plugins_vr_seat_templates` listing with v3.0 `seat_map` parsing
- `VenueSave()` — full create/update with v3.0 JSON construction
- `VenueDelete()` — soft delete with audit logging
- `countSeatsV2()` — helper for array-format seat_map (v2)
### URL Routing Analysis
| Sidebar URL in plugin.json | ucfirst chain | Expected method | Status |
|---|---|---|---|
| `/plugins/vr_ticket/admin/seatTemplateList` | `SeatTemplateList` | `SeatTemplateList()` | EXISTS |
| `/plugins/vr_ticket/admin/ticketList` | `TicketList` | `TicketList()` | EXISTS |
| `/plugins/vr_ticket/admin/verifierList` | `VerifierList` | `VerifierList()` | EXISTS |
| `/plugins/vr_ticket/admin/verificationList` | `VerificationList` | `VerificationList()` | EXISTS |
| `/plugins/vr_ticket/admin/venueList` | `VenueList` | `VenueList()` | **WAS MISSING — NOW ADDED** |
---
## P3 — View Path Security
### Analysis
The view paths in `Admin.php` are all **hardcoded strings** (not user input):
```php
return view('seat_template/list', [...]); // hardcoded
return view('ticket/list', [...]); // hardcoded
return view('venue/list', [...]); // hardcoded
```
ThinkPHP resolves these relative to the current controller's view directory (`app/plugins/vr_ticket/admin/view/`). **No path traversal risk.**
The `Vrticket.php` (main controller) uses:
```php
return MyView('plugins/view/vr_ticket/admin/view/' . $template);
```
Template values like `'seat_template/list'` are also hardcoded in each method. **Low risk.**
### Security Verdict
- **Path traversal**: LOW RISK — all paths are hardcoded method names
- **Information disclosure**: LOW RISK — ThinkPHP's view resolution returns a clear error if template missing
---
## SQL Injection Analysis
### Safe Patterns (All queries use ThinkPHP query builder with parameter binding)
```php
// SAFE — column name hardcoded, value parameterized
$where[] = ['name', 'like', "%{$name}%"];
$where[] = ['status', '=', intval($status)];
// SAFE — query builder with array $where
$list = \Db::name('plugins_vr_seat_templates')->where($where)->paginate(20);
// SAFE — direct intval on foreign key
$id = input('id', 0, 'intval');
\Db::name('...')->where('id', $id)->find();
```
### Minor Concern
```php
$where[] = ['name', 'like', "%{$name}%"]; // $name comes from input('name', '', null)
// input() with null filter = no type coercion
// If $name contains special chars like % or _, they become part of the LIKE pattern
// Low risk — only affects own query results, no data exfiltration
```
**Recommendation**: Consider escaping `%` and `_` in LIKE patterns if `$name` can be user-supplied without validation.
### Verdict: LOW RISK
All database access uses ThinkPHP's query builder. No raw SQL. No string concatenation in queries.
---
## XSS Analysis
### ThinkPHP Template Auto-Escape
All views use `{$var.property}` syntax which auto-escapes HTML:
```html
<!-- ticket/detail.html line 30 -->
<div class="detail-value">{$ticket.ticket_code}</div> <!-- Safe: auto-escaped -->
```
### Potential Risk
If any view uses `{$data|raw}` or `{*variable*}` (raw output), XSS is possible. Quick scan of available templates shows all using `{$var}` safe syntax.
### JSON API Responses
All `DataReturn()` calls return JSON — no HTML output, no XSS risk.
### Verdict: LOW RISK
ThinkPHP template engine provides auto-escaping. No raw output detected.
---
## CSRF Analysis
### Finding: No CSRF Protection
ShopXO does not implement CSRF token validation for admin POST requests. All POST endpoints in `Admin.php` are vulnerable to CSRF:
- `SeatTemplateSave()` — create/update seat template
- `TicketVerify()` — manually verify ticket
- `VerifierSave()` — add/update verifier
- `VerifierDelete()` — disable verifier
- `VenueSave()` — create/update venue
- `VenueDelete()` — disable venue
### Risk Assessment
**MEDIUM** — Requires authenticated admin session. An attacker could:
- Trick admin into clicking a link that deletes/verifies records
- Social engineering attack vector
### Recommendation
ShopXO should implement CSRF middleware globally. For this plugin, the framework-level fix is needed (not plugin-level).
### Verdict: MEDIUM (framework-level issue, not plugin-specific)
---
## Summary
| Issue | Severity | Status |
|---|---|---|
| P1: Garbled sidebar name | LOW (UX bug) | DB fix documented |
| P2: Missing VenueList() | **HIGH** (blocks navigation) | **FIXED** |
| SQL Injection | LOW | No issues found |
| XSS | LOW | Auto-escaping confirmed |
| Path Traversal | LOW | Hardcoded paths only |
| CSRF | MEDIUM (framework) | Out of scope for plugin |
---
## Fixes Applied This Round
1. **Added `VenueList()`** to `admin/Admin.php` — critical, sidebar URL was broken
2. **Added `VenueSave()`** with full v3.0 JSON construction and validation
3. **Added `VenueDelete()`** with audit logging
4. **Added `countSeatsV2()`** helper for array-format seat_map parsing
## Still Needed
1. **DB fix for P1** — run the SQL UPDATE to fix garbled plugin name in `shx_plugins` table
2. **Verify in browser** — confirm sidebar shows `VR票务` and all 5 menu items work
3. **Clean up old controllers**`admin/controller/` subdirectory controllers (Venue.php, SeatTemplate.php, etc.) are now orphaned; consider removing or deprecating

View File

@ -478,6 +478,212 @@ class Admin extends Common
return DataReturn('已禁用', 0);
}
// ============================================================
// 场馆配置Venue
// 视图: admin/view/venue/{action}.html
// 注意admin/controller/Venue.php 的旧控制器仍在使用,
// 但新路由走 Admin.php 的 VenueList/VenueSave。
// ============================================================
/**
* 场馆列表
* URL: /plugins/vr_ticket/admin/venueList
*/
public function VenueList()
{
$where = [];
$name = input('name', '', null);
if ($name !== '') {
$where[] = ['name', 'like', "%{$name}%"];
}
$status = input('status', '', null);
if ($status !== '' && $status !== null) {
$where[] = ['status', '=', intval($status)];
}
$list = \Db::name('plugins_vr_seat_templates')
->where($where)
->order('id', 'desc')
->paginate(20)
->toArray();
// 解析 venue.name 和座位数v3.0 格式seat_map.venue.name
foreach ($list['data'] as &$item) {
$seatMap = json_decode($item['seat_map'] ?? '{}', true);
$item['venue_name'] = $seatMap['venue']['name'] ?? $item['name'];
$item['venue_address'] = $seatMap['venue']['address'] ?? '';
$item['zone_count'] = !empty($seatMap['sections']) ? count($seatMap['sections']) : 0;
$item['seat_count'] = $this->countSeatsV2($seatMap);
}
unset($item);
return view('venue/list', [
'list' => $list['data'],
'page' => $list['page'],
'count' => $list['total'],
]);
}
/**
* 添加/编辑场馆
* URL: /plugins/vr_ticket/admin/venueSave
*/
public function VenueSave()
{
$id = input('id', 0, 'intval');
if (IS_AJAX_POST) {
$data = [
'name' => input('name', '', null, 'trim'),
'category_id' => input('category_id', 0, 'intval'),
'status' => input('status', 1, 'intval'),
'upd_time' => time(),
];
if (empty($data['name'])) {
return DataReturn('场馆名称不能为空', -1);
}
// v3.0 seat_map JSON 构建
$venue = [
'name' => input('venue_name', '', null, 'trim'),
'address' => input('venue_address', '', null, 'trim'),
'image' => input('venue_image', '', null, 'trim'),
];
if (empty($venue['name'])) {
return DataReturn('场馆名称不能为空', -1);
}
$zones_raw = input('zones', '[]', null, 'trim');
$zones = json_decode($zones_raw, true);
if (!is_array($zones) || empty($zones)) {
return DataReturn('请至少添加一个分区', -1);
}
$map_raw = input('seat_map_rows', '[]', null, 'trim');
$map = json_decode($map_raw, true);
if (!is_array($map) || empty($map)) {
return DataReturn('座位排布不能为空', -1);
}
$sections = [];
$seats = [];
$row_labels = [];
foreach ($zones as $zone) {
$char = strtoupper($zone['char'] ?? '');
if (empty($char)) {
return DataReturn('分区字符不能为空', -1);
}
$sections[] = [
'char' => $char,
'name' => $zone['name'] ?? '',
'color' => $zone['color'] ?? '#cccccc',
];
$seats[$char] = [
'price' => intval($zone['price'] ?? 0),
'color' => $zone['color'] ?? '#cccccc',
'label' => $zone['name'] ?? '',
];
}
foreach ($map as $rowStr) {
$rowStr = trim($rowStr);
if (empty($rowStr)) {
return DataReturn('座位排布每行不能为空', -1);
}
foreach (str_split($rowStr) as $char) {
if ($char !== '_' && !isset($seats[$char])) {
return DataReturn("座位排布中字符 '{$char}' 未在分区中定义", -1);
}
}
$row_labels[] = $rowStr[0];
}
$data['seat_map'] = json_encode([
'venue' => $venue,
'map' => $map,
'seats' => $seats,
'sections' => $sections,
'row_labels' => array_values(array_unique(array_merge(
array_column($sections, 'char'),
$row_labels
))),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($id > 0) {
\Db::name('plugins_vr_seat_templates')->where('id', $id)->update($data);
return DataReturn('更新成功', 0);
} else {
$data['add_time'] = time();
$data['upd_time'] = time();
$data['spec_base_id_map'] = '';
\Db::name('plugins_vr_seat_templates')->insert($data);
return DataReturn('添加成功', 0);
}
}
$info = [];
if ($id > 0) {
$row = \Db::name('plugins_vr_seat_templates')->find($id);
if (!empty($row)) {
$seatMap = json_decode($row['seat_map'] ?? '{}', true);
$row['venue_name'] = $seatMap['venue']['name'] ?? '';
$row['venue_address'] = $seatMap['venue']['address'] ?? '';
$row['venue_image'] = $seatMap['venue']['image'] ?? '';
$row['zones_json'] = json_encode($seatMap['sections'] ?? [], JSON_UNESCAPED_UNICODE);
$row['venue_json'] = json_encode([
'name' => $seatMap['venue']['name'] ?? '',
'address' => $seatMap['venue']['address'] ?? '',
'image' => $seatMap['venue']['image'] ?? '',
], JSON_UNESCAPED_UNICODE);
$row['map_json'] = json_encode($seatMap['map'] ?? [], JSON_UNESCAPED_UNICODE);
$info = $row;
}
}
$categories = \Db::name('GoodsCategory')
->where('is_delete', 0)
->order('id', 'asc')
->select();
return view('venue/save', [
'info' => $info,
'categories' => $categories,
]);
}
/**
* 删除场馆(软删除)
*/
public function VenueDelete()
{
if (!IS_AJAX_POST) {
return DataReturn('非法请求', -1);
}
$id = input('id', 0, 'intval');
if ($id <= 0) {
return DataReturn('参数错误', -1);
}
$template = \Db::name('plugins_vr_seat_templates')->where('id', $id)->find();
\Db::name('plugins_vr_seat_templates')
->where('id', $id)
->update(['status' => 0, 'upd_time' => time()]);
\app\plugins\vr_ticket\service\AuditService::log(
\app\plugins\vr_ticket\service\AuditService::ACTION_DISABLE_TEMPLATE,
\app\plugins\vr_ticket\service\AuditService::TARGET_TEMPLATE,
$id,
['before_status' => $template['status'] ?? 1],
$template ? "场馆: {$template['name']}" : "ID:{$id}"
);
return DataReturn('删除成功', 0);
}
// ============================================================
// 核销记录Verification
// 视图: admin/view/verification/{action}.html
@ -564,7 +770,7 @@ class Admin extends Common
// ============================================================
/**
* 统计座位数(来自原 SeatTemplate
* 统计座位数(v1 格式:直接传入 seat_map JSON 字符串
*/
private function countSeats($seat_map_json)
{
@ -585,4 +791,23 @@ class Admin extends Common
}
return $count;
}
/**
* 统计座位数v2 格式:直接传入已解码的 seat_map 数组)
*/
private function countSeatsV2(array $seat_map)
{
if (empty($seat_map['seats']) || empty($seat_map['map'])) {
return 0;
}
$count = 0;
foreach ($seat_map['map'] as $row) {
foreach (str_split($row) as $char) {
if ($char !== '_' && isset($seat_map['seats'][$char])) {
$count++;
}
}
}
return $count;
}
}