vr-shopxo-plugin/reviews/SecurityEngineer-AUDIT.md

11 KiB
Raw Permalink Blame History

安全审计报告AdminGoodsSaveHandle 数据验证逻辑

审计员: council/SecurityEngineer 日期: 2026-04-20 目标: AdminGoodsSaveHandle.php save_thing_end 时机bbea35d83 改动) 报告类型: 根因分析 + 修复建议


执行摘要

商品保存时报错 Undefined array key "id",根因定位在 AdminGoodsSaveHandle.php:77array_filter 回调中直接访问 $r['id'],当 seat_map.rooms[] 中存在缺失 id 字段的房间对象时触发。此外还发现 3 个次要风险点。


Q1: "Undefined array key 'id'" 最可能出现在哪一行?

所有涉及 id 访问的位置

行号 代码 安全性 说明
77 $r['id'] ⚠️ 不安全 array_filter 回调内,无空安全保护 → Primary 错误源
68 $config['template_id'] 安全 ?? 0 兜底
71 $template['seat_map'] ⚠️ 见 Q3 find() 可能返回 null
103 $config['template_id'] 安全 同 68
76 $config['selected_rooms'] ⚠️ 见 Q5 可能不存在或类型不匹配
101 $config['template_id'] 安全 同 68
103 $config['selected_rooms'] ⚠️ 见 Q5 同 76
104 $config['selected_sections'] 安全 ?? [] 兜底
105 $config['sessions'] 安全 ?? [] 兜底

Primary 根因99% 命中)

// AdminGoodsSaveHandle.php:75-79
$selectedRoomIds = array_column(
    array_filter($allRooms, function ($r) use ($config) {
        return in_array($r['id'], $config['selected_rooms'] ?? []);  // ← 第 77 行崩溃
    }), null
);

触发条件vr_seat_templates.seat_map.rooms[] 中任一房间对象缺少 id 键。

ShopXO 存储座位图时,如果前端 JSON 序列化或数据库写入过程中出现以下情况:

  • 某个房间在模板编辑时被删除了 id 字段
  • 历史数据从旧版模板迁移时 id 字段丢失
  • 前端构造房间对象时使用了非标准字段名(如 roomId 而非 id

$r['id'] 直接触发 Undefined array key "id"


Q2: 表前缀问题 — Db::name() vs BaseService::table()

分析结论:等价,不存在问题

调用方式 等价 SQL 表名 说明
Db::name('vr_seat_templates') {prefix}vr_seat_templates ShopXO 自动加全局前缀
BaseService::table('seat_templates') 返回 'vr_seat_templates' {prefix}vr_seat_templates 插件前缀层叠加
Db::name(BaseService::table('seat_templates')) {prefix}vrt_vr_seat_templates 双重前缀(错误)

实际使用的两种写法

位置 写法 实际查询表 正确?
AdminGoodsSaveHandle:70 Db::name('vr_seat_templates') {prefix}vr_seat_templates 正确
SeatSkuService:52 Db::name(self::table('seat_templates')) {prefix}vrt_vr_seat_templates ⚠️ 需确认前缀配置

ShopXO 前缀配置分析

ShopXO 的 Db::name() 根据插件名自动加上插件专属前缀。BaseService::table() 手动加 vr_,两者组合会产生 双重前缀。但如果 ShopXO 的全局前缀为空(prefix = ''),两种写法等价。

结论BackendArchitect 和 DebugAgent 已确认 Db::name('vr_seat_templates') 等价于 Db::name(self::table('seat_templates'))表前缀不是本次错误的原因


Q3: find($templateId) 返回 null 时的行为

// AdminGoodsSaveHandle.php:70-71
$template = Db::name('vr_seat_templates')->find($templateId);
$seatMap  = json_decode($template['seat_map'] ?? '{}', true);  // ← 若 $template 为 null

风险评估Secondary 根因

$templateId > 0 但模板记录不存在时:

  • $templatenull
  • $template['seat_map']"Undefined array key 'seat_map'"PHP 8.x 报 Warning/Error
  • PHP 8.0+ 中 null['key'] 直接抛出 Error,而非返回 null

现有代码已有部分防御

SeatSkuService::BatchGenerate:55 有正确防御:

if (empty($template)) {
    return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
}

AdminGoodsSaveHandle 中没有类似防御。


Q4: $configs JSON 解码后的类型安全性

分析结论:部分安全

// AdminGoodsSaveHandle.php:61-64
$rawConfig = $data['vr_goods_config'] ?? '';
if (!empty($rawConfig)) {
    $configs = json_decode($rawConfig, true);
    if (is_array($configs) && !empty($configs)) {  // ← ✅ 有类型检查

安全点

  • is_array($configs) 确保不是 null 或标量
  • !empty($configs) 排除空数组

潜在盲点

  • json_decode 失败时返回 null,被 is_array 挡掉
  • $configs数组的数组[[...]] vs [...]?代码使用 foreach ($configs as $i => &$config) 兼容两者(每层都是关联数组或索引数组)
  • $config['template_id'] 访问有 ?? 0 兜底

Q5: selected_rooms 数据结构与类型匹配

分析结论:静默逻辑错误风险

根据 VR_GOODS_CONFIG_SPEC.md:

"selected_rooms": ["room_id_1776341371905", "room_id_1776341444657"]

字符串数组

类型匹配问题

// AdminGoodsSaveHandle.php:77
return in_array($r['id'], $config['selected_rooms'] ?? []);
//                               ↑ 房间 'id'(字符串)
//                     ↑ selected_rooms 元素(也是字符串) ✅ 类型一致

实际类型匹配是正确的(两者都是字符串)。

但存在以下静默错误风险:

风险场景 原因 后果
$r['id'] 缺失Primary 房间对象无 id 直接崩溃
selected_rooms 为空数组 用户未选房间 array_filter 返回空,rooms 写入空数组
selected_rooms 包含无效 ID 前端传了不存在的 room_id 所有房间被过滤掉,静默空结果

对比SeatSkuService 的安全写法

// SeatSkuService.php:99-100正确的防御性写法
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);

AdminGoodsSaveHandle 缺少这个 fallback。


Q6: SeatSkuService::BatchGenerate$data['item_type'] 访问安全性

SeatSkuService::BatchGenerate 安全

  • 参数都有类型声明(int, array
  • $rooms 遍历时有空安全:$room['id'] 有 fallback (room_$rIdx)
  • $selectedSections 访问有 ?? [] 兜底
  • empty($template) 检查存在

$data['item_type'] 访问 安全

// AdminGoodsSaveHandle.php:59
if ($goodsId > 0 && ($data['item_type'] ?? '') === 'ticket') {
  • ?? '' 兜底,空值时条件为 false,不会进入票务处理分支
  • item_typesave_handle 时机中自己写入的Line 26: $params['data']['item_type'] = 'ticket'),逻辑自洽

综合根因总结

根因分级

级别 位置 问题 影响
P0 — Primary AdminGoodsSaveHandle.php:77 $r['id'] 无空安全,房间缺字段时直接崩溃 保存商品立即 500
P1 — Secondary AdminGoodsSaveHandle.php:71 find() 返回 null 后直接访问 $template['seat_map'] 模板不存在时崩溃
P2 — Tertiary AdminGoodsSaveHandle.php:75-79 selected_rooms 类型/存在性验证不足 静默空结果
P3 — Info AdminGoodsSaveHandle.php:91-93 JSON 编码异常(json_encode 失败)无捕获 数据回写失败

与 BackendArchitect 评审的一致性

本报告与 BackendArchitect 的 reviews/BackendArchitect-on-Issue-13-debug.md 结论一致:

  • Primary 根因Line 77 $r['id'] 无空安全
  • Secondaryfind() 返回 null
  • Tertiaryselected_rooms 类型不匹配 (本报告进一步确认为静默风险,非直接崩溃)

修复建议

P0 修复(一行改动)

// AdminGoodsSaveHandle.php:74-79修复后
$selectedRoomIds = array_column(
    array_filter($allRooms, function ($r) use ($config) {
        return !empty($r['id']) && in_array($r['id'], $config['selected_rooms'] ?? []);
    }), null
);

添加 !empty($r['id']) 前置检查,与 SeatSkuService:100 的防御策略一致。

P1 修复(添加模板存在性检查)

// AdminGoodsSaveHandle.php:69-71修复后
if ($templateId > 0) {
    $template = Db::name('vr_seat_templates')->find($templateId);
    if (empty($template)) {
        continue;  // 跳过无效模板,不阻塞整个保存流程
    }
    $seatMap  = json_decode($template['seat_map'] ?? '{}', true);

建议的完整防御代码

// 填充 template_snapshot前端没传时兜底从 vr_seat_templates 读)
foreach ($configs as $i => &$config) {
    if (empty($config['template_snapshot'])) {
        $templateId = intval($config['template_id'] ?? 0);
        if ($templateId > 0) {
            $template = Db::name('vr_seat_templates')->find($templateId);
            if (empty($template)) {
                continue;  // P1: 跳过不存在的模板
            }
            $seatMap  = json_decode($template['seat_map'] ?? '{}', true);
            $allRooms = $seatMap['rooms'] ?? [];

            // P0: 先过滤掉无 id 的脏数据,再按 selected_rooms 过滤
            $validRooms = array_filter($allRooms, function ($r) {
                return !empty($r['id']);  // P0 修复
            });
            $selectedRoomIds = array_column(
                array_filter($validRooms, function ($r) use ($config) {
                    return in_array($r['id'], $config['selected_rooms'] ?? []);
                }), null
            );

            $config['template_snapshot'] = [
                'venue' => $seatMap['venue'] ?? [],
                'rooms' => $selectedRoomIds,
            ];
        }
    }
}
unset($config);

PHP 版本兼容性

PHP 版本 null['key'] 行为 find() 返回 null 时
PHP 7.x 返回 nullUndefined index Warning 访问 $template['seat_map'] → Warning
PHP 8.0+ 抛出 TypeError 同上

本项目应确认生产环境 PHP 版本,以评估错误级别。


结论

"Undefined array key 'id'" 的根因是 AdminGoodsSaveHandle.php:77 直接访问 $r['id'] 而未检查键是否存在。当 seat_map.rooms[] 中存在脏数据(缺失 id 字段的房间对象PHP 直接崩溃。

最简修复:在 array_filter 回调中添加 !empty($r['id']) 前置条件,与同项目中 SeatSkuService::BatchGenerate:100 的已有防御模式保持一致。


报告生成时间: 2026-04-20 审计员: council/SecurityEngineer