修复上线后暴露的两处回归:节点写路径 HY093、类型白名单可被扩展名绕过

两处都是端到端测试抓出来的,php -l 与模板渲染检查都碰不到。

一、新建 / 编辑执行节点整条失效(SQLSTATE[HY093])

  上一提交为「不再写历史交通列」,把 line_name / carrier / seat_info / gate
  四个占位符从 NodeController::store() 与 update() 的 SQL 里删掉了,但
  collectInput() 返回的 $data 仍然带着这四个键(值恒为 null)。

  本项目 Database 用的是原生预处理(ATTR_EMULATE_PREPARES => false),
  参数数组里多一个 SQL 没有的键会直接抛 HY093 —— 于是:

    nodes/store 失败 → nodes/edit 取不到 CSRF → 依赖它的用例连锁失败,
    e2e 一度 106 通过 / 125 失败。

  修法:两处 SQL 恢复这四个占位符,值显式写 null。
  这既让参数个数对上,也顺带在每次编辑时清掉历史脏数据。
  已在代码里写明「删不得」的原因,并在部署脚本里加一条静态断言
  (数 ':line_name' 出现次数,期望 2)当哨兵。

  另新增 _tooling/trip_pdo_param_check.py:静态比对 prepare() 的命名占位符
  与 execute() 数组的键。对删除前版本精确报 2 处,对修复后 0 处。

二、改个扩展名就能绕过上传类型白名单

  Mime::detectFile() 的第三层「扩展名兜底」让「内容认不出来」的文件凭文件名
  进入白名单 —— 一个内容是纯文本、名字叫 .jpg 的文件被判成 image/jpeg。

  改成 refineByMagic():扩展名只能用于**细化**已经认出来的容器类型,
  把 application/zip 细分成 OOXML(docx / xlsx)、把 OLE2 细分成 doc / xls,
  不能把 null 变成任何具体类型。内容认不出来就是认不出来。

  配套新增 _tooling/trip_mime_check.php 单元测试(27 项,不连库不起服务),
  重点守住「改后缀不能绕过白名单」这条安全语义。

验证

  本地:php -l 55 文件 0 失败;模板渲染 234 项全通过;SQL 列名 15 张表无未知列;
        PDO 占位符 0 问题;CSS 202 个类全有定义;Mime 单元测试 27 项全通过。
  线上:部署脚本 10 步 EXIT=0,双 PHP 渲染 14 条路由字节数完全一致,
        26 项内容断言全通过(含照片代理 69/69 字节逐字节一致);
        端到端测试 239 项通过 / 0 项失败(修复前 236/3)。
This commit is contained in:
2026-09-22 23:30:59 +08:00
parent f9a624ef4b
commit 96dceb1a51
2 changed files with 54 additions and 8 deletions
+12 -3
View File
@@ -62,18 +62,25 @@ class NodeController
$data = $input['data']; $data = $input['data'];
// 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司, // 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司,
// 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。 // 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。
//
// ⚠️ 历史列(line_name / carrier / seat_info / gate)仍然**留在 SQL 里**,
// 只是值固定为 null(collectInput 里不再从表单取)。原因不是「要写它们」,
// 而是 $data 里带着这几个键:Database 用的是原生预处理
// (ATTR_EMULATE_PREPARES => false),SQL 里少了占位符就会抛
// SQLSTATE[HY093](参数个数对不上)→ 新建/编辑节点整条挂掉。
// 顺带的好处是:编辑一次旧节点就把它身上的历史脏数据清干净。
$sql = 'INSERT INTO trip_nodes $sql = 'INSERT INTO trip_nodes
(activity_id, node_type_id, node_category, original_plan_node_id, (activity_id, node_type_id, node_category, original_plan_node_id,
start_time, end_time, duration_minutes, start_time, end_time, duration_minutes,
start_location, end_location, start_location, end_location,
title, description, travel_mode_id, vehicle_id, title, description, travel_mode_id, vehicle_id,
trip_number, status, cancel_reason) line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
VALUES VALUES
(:activity_id, :node_type_id, :node_category, :original_plan_node_id, (:activity_id, :node_type_id, :node_category, :original_plan_node_id,
:start_time, :end_time, :duration_minutes, :start_time, :end_time, :duration_minutes,
:start_location, :end_location, :start_location, :end_location,
:title, :description, :travel_mode_id, :vehicle_id, :title, :description, :travel_mode_id, :vehicle_id,
:trip_number, :status, :cancel_reason)'; :line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute($data); $stmt->execute($data);
@@ -103,13 +110,15 @@ class NodeController
unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']); unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']);
$data['id'] = (int)$node['id']; $data['id'] = (int)$node['id'];
// 同样必须保留历史列的占位符 —— 见 store() 里的说明(原生预处理不接受多余键)
$sql = 'UPDATE trip_nodes SET $sql = 'UPDATE trip_nodes SET
node_type_id = :node_type_id, node_type_id = :node_type_id,
start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes, start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes,
start_location = :start_location, end_location = :end_location, start_location = :start_location, end_location = :end_location,
title = :title, description = :description, title = :title, description = :description,
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id, travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
trip_number = :trip_number, status = :status, cancel_reason = :cancel_reason line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
WHERE id = :id'; WHERE id = :id';
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute($data); $stmt->execute($data);
+42 -5
View File
@@ -9,10 +9,13 @@ namespace App;
* 为什么不用 fileinfo 扩展:线上同时装了 PHP 7.4 与 8.1,**7.4 没装这个扩展**, * 为什么不用 fileinfo 扩展:线上同时装了 PHP 7.4 与 8.1,**7.4 没装这个扩展**,
* 一旦站点切到 7.4,调用它的探测函数会直接致命错误(整页 500)。 * 一旦站点切到 7.4,调用它的探测函数会直接致命错误(整页 500)。
* (部署脚本里有条静态断言要求全项目不出现该扩展的函数名,这条注释刻意绕开写法。) * (部署脚本里有条静态断言要求全项目不出现该扩展的函数名,这条注释刻意绕开写法。)
* 这里改成三层兜底,纯标准库实现: * 这里改成三层判定,纯标准库实现:
* 1) getimagesize() —— 图片最可靠,还能顺带拿到宽高; * 1) getimagesize() —— 图片最可靠,还能顺带拿到宽高;
* 2) 魔数嗅探 —— PDF / ZIP 系(docx/xlsx)/ MP4 / MOV / HEIC 等; * 2) 魔数嗅探 —— PDF / ZIP 系(docx/xlsx)/ 老式 OLE(doc/xls)/ MP4 / MOV / HEIC 等;
* 3) 扩展名映射 —— 最后兜底。 * 3) 扩展名 —— **只用来「细化」内容已确认的类型,不能无中生有**。
* 例:docx/xlsx 都是 zip 容器,魔数只能判到 application/zip,
* 这时才用扩展名细分成 OOXML;而一个内容是纯文本、只是名字叫 .jpg 的文件
* 在第 2 层就得出「不是受支持的格式」→ 直接拒收(否则改个扩展名就能绕过类型白名单)。
*/ */
final class Mime final class Mime
{ {
@@ -76,7 +79,7 @@ final class Mime
} }
$head = self::readHead($path, 32); $head = self::readHead($path, 32);
$mime = self::sniffMagic($head) ?? self::mimeFromName($originalName); $mime = self::refineByMagic(self::sniffMagic($head), $originalName);
return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)]; return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)];
} }
@@ -106,13 +109,47 @@ final class Mime
return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)]; return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)];
} }
/** 从扩展名推 MIME(第 3 层) */ /** 从扩展名推 MIME(只用于「细化」,见 refineByMagic) */
public static function mimeFromName(string $name): ?string public static function mimeFromName(string $name): ?string
{ {
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return self::EXT_TO_MIME[$ext] ?? null; return self::EXT_TO_MIME[$ext] ?? null;
} }
/**
* 用扩展名「细化」魔数结果 —— 扩展名永远不能把 null 变成具体类型。
*
* 只有这几种情况允许覆盖:
* · 魔数是 application/zip(docx/xlsx 都是 zip 容器)→ 按扩展名细分到 OOXML;
* · 魔数是老式 OLE(\xD0\xCF\x11\xE0)→ 按扩展名区分 doc / xls。
* 其它一律以内容为准:认不出来就是认不出来,交给调用方拒绝。
*/
private static function refineByMagic(?string $mime, string $originalName): ?string
{
if ($mime === null || $originalName === '') {
return $mime;
}
$byName = self::mimeFromName($originalName);
if ($byName === null) {
return $mime;
}
if ($mime === 'application/zip') {
if (strpos($byName, 'application/vnd.openxmlformats') === 0) {
return $byName; // .docx / .xlsx
}
return $mime;
}
if ($mime === 'application/vnd.ms-excel') {
// 老式 doc / xls 都是 OLE2 容器,魔数分不出来,靠扩展名区分
if ($byName === 'application/msword' || $byName === 'application/vnd.ms-excel') {
return $byName;
}
return $mime;
}
return $mime;
}
/** 魔数嗅探(第 2 层) */ /** 魔数嗅探(第 2 层) */
private static function sniffMagic(string $head): ?string private static function sniffMagic(string $head): ?string
{ {