mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 21:40:50 +00:00
fix(安全解压): 新增 SafeZip,主题/插件 ZIP 防路径穿越;登记主题导入权限
商品 Excel 导入空单元格统一为空串,并补齐 product_type 列映射。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a07b6d011a
commit
89a8ea13d8
@ -18,6 +18,7 @@ use app\services\diy\ThemeDownloadServices;
|
||||
use app\services\diy\ThemeServices;
|
||||
use app\services\product\CenterProductServices;
|
||||
use app\services\system\module\ModuleInstaller;
|
||||
use crmeb\utils\SafeZip;
|
||||
use SplFileInfo;
|
||||
use think\facade\App;
|
||||
|
||||
@ -352,16 +353,21 @@ class ThemeController extends AuthController
|
||||
*/
|
||||
public function importTheme()
|
||||
{
|
||||
// 1 获取文件
|
||||
// 1 获取文件:url 必须落在 public/uploads 内,禁止路径穿越读取任意文件
|
||||
[$importUrl] = $this->request->postMore([
|
||||
['url', ''],
|
||||
], true);
|
||||
$realPath = public_path() . $importUrl;
|
||||
if (!file_exists($realPath)) return app('json')->fail('文件不存在');
|
||||
try {
|
||||
$realPath = $this->resolveThemeImportZip((string)$importUrl);
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail($e->getMessage() ?: '文件不存在或路径非法');
|
||||
}
|
||||
|
||||
// 2. 解压文件到 theme/import/ 目录
|
||||
$dir = 'theme/import/';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
// 2. 解压到 public/theme/import/(绝对路径,禁止 CWD 相对解压)
|
||||
$dir = rtrim(public_path('theme' . DIRECTORY_SEPARATOR . 'import'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return app('json')->fail('无法创建主题导入目录');
|
||||
}
|
||||
// 清理导入目录,防止数据污染
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
@ -375,10 +381,11 @@ class ThemeController extends AuthController
|
||||
@unlink($fileInfo->getRealPath());
|
||||
}
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($realPath);
|
||||
$zip->extractTo($dir);
|
||||
$zip->close();
|
||||
try {
|
||||
SafeZip::extract($realPath, $dir);
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail('主题包解压失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 3. 读取解压后的 config.json 文件
|
||||
$configPath = $dir . 'config.json';
|
||||
@ -389,8 +396,11 @@ class ThemeController extends AuthController
|
||||
// 4. 处理图片资源迁移
|
||||
// 将压缩包里面的所有图片,移动到 uploads/theme/{时间戳}/ 文件夹下
|
||||
$timestamp = date('YmdHis');
|
||||
$themeDir = 'uploads/theme/' . $timestamp . '/';
|
||||
if (!is_dir($themeDir)) mkdir($themeDir, 0755, true);
|
||||
$themeDirRel = 'uploads/theme/' . $timestamp . '/';
|
||||
$themeDirAbs = rtrim(public_path('uploads' . DIRECTORY_SEPARATOR . 'theme' . DIRECTORY_SEPARATOR . $timestamp), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($themeDirAbs) && !mkdir($themeDirAbs, 0755, true) && !is_dir($themeDirAbs)) {
|
||||
return app('json')->fail('无法创建主题图片目录');
|
||||
}
|
||||
|
||||
$rootPath = realpath($dir);
|
||||
$imageMap = []; // 记录相对路径到新上传路径的映射
|
||||
@ -411,11 +421,11 @@ class ThemeController extends AuthController
|
||||
$relative = ltrim(str_replace($rootPath, '', $filePath), DIRECTORY_SEPARATOR);
|
||||
$basename = basename($filePath);
|
||||
|
||||
// 目标路径
|
||||
$target = $themeDir . $basename;
|
||||
// 移动/复制文件
|
||||
if (@copy($filePath, $target)) {
|
||||
$imageMap[$relative] = $target; // 记录映射:包内相对路径 => 新系统路径
|
||||
// 目标路径(磁盘绝对路径写入,映射仍用站点相对路径)
|
||||
$targetAbs = $themeDirAbs . $basename;
|
||||
$targetRel = $themeDirRel . $basename;
|
||||
if (@copy($filePath, $targetAbs)) {
|
||||
$imageMap[$relative] = $targetRel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -575,4 +585,34 @@ class ThemeController extends AuthController
|
||||
$data = $this->services->getMicroPageList();
|
||||
return app('json')->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将主题导入 url(完整 URL /uploads 相对路径)解析为 uploads 目录内的本地 ZIP 路径。
|
||||
*/
|
||||
protected function resolveThemeImportZip(string $importUrl): string
|
||||
{
|
||||
$importUrl = trim($importUrl);
|
||||
if ($importUrl === '') {
|
||||
throw new \RuntimeException('请先上传主题包');
|
||||
}
|
||||
|
||||
$path = $importUrl;
|
||||
if (preg_match('#^https?://#i', $importUrl)) {
|
||||
$parts = parse_url($importUrl);
|
||||
$path = (string)($parts['path'] ?? '');
|
||||
}
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = ltrim($path, '/');
|
||||
if (str_starts_with($path, 'uploads/')) {
|
||||
$path = substr($path, strlen('uploads/'));
|
||||
}
|
||||
if ($path === '' || str_contains($path, '..')) {
|
||||
throw new \RuntimeException('文件不存在或路径非法');
|
||||
}
|
||||
if (!preg_match('/\.zip$/i', $path)) {
|
||||
throw new \RuntimeException('仅支持 ZIP 主题包');
|
||||
}
|
||||
|
||||
return SafeZip::resolveUnder(public_path('uploads'), $path);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ use app\services\system\module\ModuleInstaller;
|
||||
use app\services\system\module\CreateModuleInstallLogTable;
|
||||
use crmeb\plugin\PluginManager;
|
||||
use crmeb\plugin\PluginManifest;
|
||||
use crmeb\utils\SafeZip;
|
||||
use think\facade\App;
|
||||
|
||||
/**
|
||||
@ -141,26 +142,22 @@ class PluginController extends AuthController
|
||||
}
|
||||
|
||||
$targetDir = app()->getRootPath();
|
||||
$tmpDir = '';
|
||||
|
||||
try {
|
||||
// 临时目录放到 public/uploads/ 下,方便统一管理
|
||||
$tmpDir = $targetDir . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . '.plugin_tmp_' . uniqid() . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($tmpDir)) {
|
||||
mkdir($tmpDir, 0755, true);
|
||||
// 临时目录放在 runtime(Web 根外),避免解压物被 HTTP 直接访问执行
|
||||
$tmpRoot = $targetDir . 'runtime' . DIRECTORY_SEPARATOR . 'plugin_upload' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($tmpRoot) && !mkdir($tmpRoot, 0755, true) && !is_dir($tmpRoot)) {
|
||||
return app('json')->fail('无法创建插件上传临时目录');
|
||||
}
|
||||
$tmpDir = $tmpRoot . '.plugin_tmp_' . uniqid('', true) . DIRECTORY_SEPARATOR;
|
||||
if (!mkdir($tmpDir, 0755, true) && !is_dir($tmpDir)) {
|
||||
return app('json')->fail('无法创建插件上传临时目录');
|
||||
}
|
||||
|
||||
$zipPath = $tmpDir . 'package.zip';
|
||||
$file->move($tmpDir, 'package.zip');
|
||||
|
||||
// 解压到临时目录
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
$this->cleanTempDir($tmpDir);
|
||||
return app('json')->fail('无法打开 ZIP 文件,可能已损坏');
|
||||
}
|
||||
$this->validateZipEntries($zip);
|
||||
$zip->extractTo($tmpDir);
|
||||
$zip->close();
|
||||
SafeZip::extract($zipPath, $tmpDir, ['require_plugin_json' => true]);
|
||||
|
||||
// 查找 plugin.json(可能在根目录或一级子目录)
|
||||
$pluginJson = $tmpDir . 'plugin.json';
|
||||
@ -322,54 +319,6 @@ class PluginController extends AuthController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压前验证所有 ZIP 条目,阻止目录穿越、绝对路径、符号链接和解压炸弹。
|
||||
*/
|
||||
protected function validateZipEntries(\ZipArchive $zip): void
|
||||
{
|
||||
if ($zip->numFiles < 1 || $zip->numFiles > 2000) {
|
||||
throw new \RuntimeException('ZIP 文件数量必须在 1 到 2000 之间');
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
$manifestCount = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if (!is_array($stat) || !isset($stat['name'])) {
|
||||
throw new \RuntimeException('ZIP 条目信息损坏');
|
||||
}
|
||||
$name = str_replace('\\', '/', (string) $stat['name']);
|
||||
$segments = explode('/', $name);
|
||||
if ($name === '' || str_contains($name, "\0") || str_starts_with($name, '/')
|
||||
|| preg_match('/^[A-Za-z]:\//', $name) || in_array('..', $segments, true)) {
|
||||
throw new \RuntimeException("ZIP 包含非法路径: {$name}");
|
||||
}
|
||||
if (basename($name) === 'plugin.json') {
|
||||
$manifestCount++;
|
||||
if (count($segments) > 2) {
|
||||
throw new \RuntimeException('plugin.json 只能位于 ZIP 根目录或一级插件目录');
|
||||
}
|
||||
}
|
||||
|
||||
$totalSize += (int) ($stat['size'] ?? 0);
|
||||
if ($totalSize > 150 * 1024 * 1024) {
|
||||
throw new \RuntimeException('ZIP 解压后总体积不能超过 150MB');
|
||||
}
|
||||
|
||||
$attributes = 0;
|
||||
$opsys = 0;
|
||||
if ($zip->getExternalAttributesIndex($i, $opsys, $attributes)) {
|
||||
$fileType = ($attributes >> 16) & 0170000;
|
||||
if ($fileType === 0120000) {
|
||||
throw new \RuntimeException("ZIP 不允许包含符号链接: {$name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifestCount !== 1) {
|
||||
throw new \RuntimeException('ZIP 包必须且只能包含一个 plugin.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归复制目录
|
||||
*/
|
||||
|
||||
@ -127,7 +127,15 @@ class SystemRoleServices extends BaseService
|
||||
// AI Agent 接口可借内置 MCP 读取全站业务数据,同样 fail-closed:
|
||||
// 路由未登记权限节点时一律按无权限处理,禁止默认放行
|
||||
$requiresAiAgentAuth = str_starts_with($rule, 'aiagent/');
|
||||
if (!$requiresPrinterAuth && !$requiresAiAgentAuth && !in_array($rule, $allAuth[$method] ?? [], true)) return true;
|
||||
// ZIP 落盘写接口:未登记权限节点时不得默认放行(plugin 写接口已在菜单登记)
|
||||
$requiresZipWriteAuth = in_array($rule, [
|
||||
'theme/import',
|
||||
'plugin/refresh_notices',
|
||||
], true);
|
||||
if (!$requiresPrinterAuth && !$requiresAiAgentAuth && !$requiresZipWriteAuth
|
||||
&& !in_array($rule, $allAuth[$method] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果是crud接口放行
|
||||
if (strpos($rule, 'crud/') === 0) return true;
|
||||
|
||||
@ -905,24 +905,12 @@ class File
|
||||
*/
|
||||
public static function zipOpen(string $filename, string $savename)
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
$zipfile = $filename;
|
||||
$res = $zip->open($zipfile);
|
||||
$toDir = $savename;
|
||||
if (!file_exists($toDir)) mkdir($toDir, 0777);
|
||||
$docnum = $zip->numFiles;
|
||||
for ($i = 0; $i < $docnum; $i++) {
|
||||
$statInfo = $zip->statIndex($i);
|
||||
if ($statInfo['crc'] == 0 && $statInfo['comp_size'] != 2) {
|
||||
//新建目录
|
||||
mkdir($toDir . '/' . substr($statInfo['name'], 0, -1), 0777);
|
||||
} else {
|
||||
//拷贝文件
|
||||
copy('zip://' . $zipfile . '#' . $statInfo['name'], $toDir . '/' . $statInfo['name']);
|
||||
}
|
||||
try {
|
||||
SafeZip::extract($filename, $savename);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
$zip->close();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1054,34 +1042,42 @@ class File
|
||||
throw new AdminException('数据结构不正确');
|
||||
}
|
||||
} else {
|
||||
// 空单元格统一成空串,避免 null !== '' 触发条码重复误判、分类名异常等
|
||||
$cell = static function ($v): string {
|
||||
if ($v === null) {
|
||||
return '';
|
||||
}
|
||||
return is_scalar($v) ? trim((string)$v) : '';
|
||||
};
|
||||
$data[] = [
|
||||
'id' => $t1,
|
||||
'store_name' => $t2,
|
||||
'cate_name_one' => $t4,
|
||||
'cate_name_two' => $t5,
|
||||
'unit_name' => $t6,
|
||||
'slider_image' => $t7,
|
||||
'video_link' => $t8,
|
||||
'description' => $t9,
|
||||
'ficti' => $t10,
|
||||
'min_qty' => $t11,
|
||||
'spec_type' => $t12,
|
||||
'sku_type_value' => $t13,
|
||||
'sku_name' => $t14,
|
||||
'sku_value' => $t15,
|
||||
'pic' => $t16,
|
||||
'price' => $t17,
|
||||
'ot_price' => $t18,
|
||||
'cost' => $t19,
|
||||
'stock' => $t20,
|
||||
'weight' => $t21,
|
||||
'volume' => $t22,
|
||||
'bar_code' => $t23,
|
||||
'bar_code_number' => $t24,
|
||||
'store_info' => $t25,
|
||||
'keyword' => $t26,
|
||||
'command_word' => $t27,
|
||||
'give_integral' => $t28,
|
||||
'id' => $cell($t1),
|
||||
'store_name' => $cell($t2),
|
||||
'product_type' => $cell($t3),
|
||||
'cate_name_one' => $cell($t4),
|
||||
'cate_name_two' => $cell($t5),
|
||||
'unit_name' => $cell($t6),
|
||||
'slider_image' => $cell($t7),
|
||||
'video_link' => $cell($t8),
|
||||
'description' => $cell($t9),
|
||||
'ficti' => $cell($t10),
|
||||
'min_qty' => $cell($t11),
|
||||
'spec_type' => $cell($t12),
|
||||
'sku_type_value' => $cell($t13),
|
||||
'sku_name' => $cell($t14),
|
||||
'sku_value' => $cell($t15),
|
||||
'pic' => $cell($t16),
|
||||
'price' => $cell($t17),
|
||||
'ot_price' => $cell($t18),
|
||||
'cost' => $cell($t19),
|
||||
'stock' => $cell($t20),
|
||||
'weight' => $cell($t21),
|
||||
'volume' => $cell($t22),
|
||||
'bar_code' => $cell($t23),
|
||||
'bar_code_number' => $cell($t24),
|
||||
'store_info' => $cell($t25),
|
||||
'keyword' => $cell($t26),
|
||||
'command_word' => $cell($t27),
|
||||
'give_integral' => $cell($t28),
|
||||
];
|
||||
}
|
||||
|
||||
@ -1152,9 +1148,12 @@ class File
|
||||
return false;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($source);
|
||||
return $zip->extractTo($folder);
|
||||
try {
|
||||
SafeZip::extract($source, $folder);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
254
backend/crmeb/utils/SafeZip.php
Normal file
254
backend/crmeb/utils/SafeZip.php
Normal file
@ -0,0 +1,254 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace crmeb\utils;
|
||||
|
||||
/**
|
||||
* ZIP 安全解压:拒绝路径穿越、绝对路径、符号链接与解压炸弹,并在解压后校验落盘路径。
|
||||
*/
|
||||
class SafeZip
|
||||
{
|
||||
/**
|
||||
* 校验本地文件为 ZIP 魔数(不信任扩展名)。
|
||||
*/
|
||||
public static function assertMagic(string $zipPath): void
|
||||
{
|
||||
if (!is_file($zipPath) || !is_readable($zipPath)) {
|
||||
throw new \RuntimeException('ZIP 文件不存在或不可读');
|
||||
}
|
||||
$fh = fopen($zipPath, 'rb');
|
||||
if ($fh === false) {
|
||||
throw new \RuntimeException('无法读取 ZIP 文件');
|
||||
}
|
||||
try {
|
||||
$sig = fread($fh, 4);
|
||||
} finally {
|
||||
fclose($fh);
|
||||
}
|
||||
// 本地文件头 / 空归档 / spanned
|
||||
if (!in_array($sig, ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"], true)) {
|
||||
throw new \RuntimeException('文件不是合法的 ZIP 包');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压前校验所有条目。
|
||||
*
|
||||
* @param array{max_files?:int,max_bytes?:int,require_plugin_json?:bool} $options
|
||||
*/
|
||||
public static function validateEntries(\ZipArchive $zip, array $options = []): void
|
||||
{
|
||||
$maxFiles = (int)($options['max_files'] ?? 2000);
|
||||
$maxBytes = (int)($options['max_bytes'] ?? 150 * 1024 * 1024);
|
||||
$requirePluginJson = (bool)($options['require_plugin_json'] ?? false);
|
||||
|
||||
if ($zip->numFiles < 1 || $zip->numFiles > $maxFiles) {
|
||||
throw new \RuntimeException("ZIP 文件数量必须在 1 到 {$maxFiles} 之间");
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
$manifestCount = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if (!is_array($stat) || !isset($stat['name'])) {
|
||||
throw new \RuntimeException('ZIP 条目信息损坏');
|
||||
}
|
||||
$name = str_replace('\\', '/', (string)$stat['name']);
|
||||
self::assertSafeEntryName($name);
|
||||
|
||||
if (basename($name) === 'plugin.json') {
|
||||
$manifestCount++;
|
||||
$segments = explode('/', rtrim($name, '/'));
|
||||
if (count($segments) > 2) {
|
||||
throw new \RuntimeException('plugin.json 只能位于 ZIP 根目录或一级插件目录');
|
||||
}
|
||||
}
|
||||
|
||||
$totalSize += (int)($stat['size'] ?? 0);
|
||||
if ($totalSize > $maxBytes) {
|
||||
throw new \RuntimeException('ZIP 解压后总体积超过限制');
|
||||
}
|
||||
|
||||
$attributes = 0;
|
||||
$opsys = 0;
|
||||
if ($zip->getExternalAttributesIndex($i, $opsys, $attributes)) {
|
||||
// Unix 外部属性:高 16 位为 mode;0120000 = symlink
|
||||
if ($opsys === \ZipArchive::OPSYS_UNIX) {
|
||||
$fileType = ($attributes >> 16) & 0170000;
|
||||
if ($fileType === 0120000) {
|
||||
throw new \RuntimeException("ZIP 不允许包含符号链接: {$name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($requirePluginJson && $manifestCount !== 1) {
|
||||
throw new \RuntimeException('ZIP 包必须且只能包含一个 plugin.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解压到目标目录(逐条写出,禁止 extractTo 直落)。
|
||||
*
|
||||
* @param array{max_files?:int,max_bytes?:int,require_plugin_json?:bool} $options
|
||||
*/
|
||||
public static function extract(string $zipPath, string $destDir, array $options = []): void
|
||||
{
|
||||
self::assertMagic($zipPath);
|
||||
|
||||
$destDir = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $destDir), DIRECTORY_SEPARATOR);
|
||||
if ($destDir === '') {
|
||||
throw new \RuntimeException('解压目标目录无效');
|
||||
}
|
||||
if (!is_dir($destDir) && !mkdir($destDir, 0755, true) && !is_dir($destDir)) {
|
||||
throw new \RuntimeException('无法创建解压目录');
|
||||
}
|
||||
$destReal = realpath($destDir);
|
||||
if ($destReal === false) {
|
||||
throw new \RuntimeException('无法解析解压目录');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new \RuntimeException('无法打开 ZIP 文件,可能已损坏');
|
||||
}
|
||||
|
||||
try {
|
||||
self::validateEntries($zip, $options);
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = str_replace('\\', '/', (string)$stat['name']);
|
||||
self::assertSafeEntryName($name);
|
||||
|
||||
$relative = str_replace('/', DIRECTORY_SEPARATOR, $name);
|
||||
$target = $destReal . DIRECTORY_SEPARATOR . $relative;
|
||||
$parent = dirname($target);
|
||||
self::assertPathContained($destReal, $parent);
|
||||
|
||||
if (str_ends_with($name, '/')) {
|
||||
if (!is_dir($target) && !mkdir($target, 0755, true) && !is_dir($target)) {
|
||||
throw new \RuntimeException("无法创建目录: {$name}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) {
|
||||
throw new \RuntimeException("无法创建目录: {$name}");
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
// 部分目录条目无内容流,跳过即可
|
||||
if (($stat['size'] ?? 0) === 0) {
|
||||
continue;
|
||||
}
|
||||
throw new \RuntimeException("无法读取 ZIP 条目: {$name}");
|
||||
}
|
||||
$out = fopen($target, 'wb');
|
||||
if ($out === false) {
|
||||
fclose($stream);
|
||||
throw new \RuntimeException("无法写入文件: {$name}");
|
||||
}
|
||||
try {
|
||||
stream_copy_to_stream($stream, $out);
|
||||
} finally {
|
||||
fclose($out);
|
||||
fclose($stream);
|
||||
}
|
||||
self::assertPathContained($destReal, $target);
|
||||
}
|
||||
self::assertExtractedTree($destReal);
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将相对路径解析为 base 目录下的真实文件路径(防穿越)。
|
||||
*/
|
||||
public static function resolveUnder(string $baseDir, string $relative): string
|
||||
{
|
||||
$baseReal = realpath($baseDir);
|
||||
if ($baseReal === false || !is_dir($baseReal)) {
|
||||
throw new \RuntimeException('基准目录不存在');
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', $relative);
|
||||
$relative = ltrim($relative, '/');
|
||||
if ($relative === '' || str_contains($relative, "\0") || str_contains($relative, '..')
|
||||
|| preg_match('#^[A-Za-z]:/#', $relative)) {
|
||||
throw new \RuntimeException('非法文件路径');
|
||||
}
|
||||
|
||||
$candidate = $baseReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
$real = realpath($candidate);
|
||||
if ($real === false || !is_file($real)) {
|
||||
throw new \RuntimeException('文件不存在');
|
||||
}
|
||||
self::assertPathContained($baseReal, $real);
|
||||
return $real;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压后遍历:拒绝软链,且每个文件必须落在目标目录内。
|
||||
*/
|
||||
public static function assertExtractedTree(string $destDir): void
|
||||
{
|
||||
$root = realpath($destDir);
|
||||
if ($root === false) {
|
||||
throw new \RuntimeException('解压目录无效');
|
||||
}
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
$pathname = $file->getPathname();
|
||||
if (is_link($pathname)) {
|
||||
throw new \RuntimeException('解压结果不允许包含符号链接: ' . $file->getFilename());
|
||||
}
|
||||
$real = realpath($pathname);
|
||||
if ($real === false) {
|
||||
throw new \RuntimeException('解压结果路径无效');
|
||||
}
|
||||
self::assertPathContained($root, $real);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function assertSafeEntryName(string $name): void
|
||||
{
|
||||
$normalized = rtrim(str_replace('\\', '/', $name), '/');
|
||||
$segments = $normalized === '' ? [] : explode('/', $normalized);
|
||||
if ($name === '' || str_contains($name, "\0") || str_starts_with($name, '/')
|
||||
|| preg_match('/^[A-Za-z]:\//', $name) || str_contains($name, '//')
|
||||
|| in_array('..', $segments, true) || in_array('.', $segments, true)) {
|
||||
throw new \RuntimeException("ZIP 包含非法路径: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
protected static function assertPathContained(string $root, string $path): void
|
||||
{
|
||||
$root = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
$pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
|
||||
// 目录尚未创建时用字符串前缀;已存在则用 realpath
|
||||
if (file_exists($pathNorm)) {
|
||||
$real = realpath($pathNorm);
|
||||
if ($real === false) {
|
||||
throw new \RuntimeException('路径解析失败');
|
||||
}
|
||||
$pathNorm = $real;
|
||||
} else {
|
||||
// 规范化 .. 已在条目名阶段拒绝;此处再挡一层
|
||||
if (str_contains($pathNorm, '..' . DIRECTORY_SEPARATOR) || str_ends_with($pathNorm, '..')) {
|
||||
throw new \RuntimeException('路径越界');
|
||||
}
|
||||
}
|
||||
if ($pathNorm !== $root && !str_starts_with($pathNorm, $root . DIRECTORY_SEPARATOR)) {
|
||||
throw new \RuntimeException('路径越界,拒绝写出到目标目录之外');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -740,4 +740,5 @@ INSERT INTO `eb_system_menus` (`id`, `pid`, `icon`, `menu_name`, `module`, `cont
|
||||
(6656, 6647, '', '设置默认AI模型', 'admin', '', '', 'ai/model/set_default/<id>', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-default', 'core:setting-ai-model-default', 0, '设置默认AI模型接口权限'),
|
||||
(6657, 6647, '', 'AI模型排序', 'admin', '', '', 'ai/model/sort', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-sort', 'core:setting-ai-model-sort', 0, 'AI模型排序接口权限'),
|
||||
(6658, 6647, '', 'AI模型连通性测试', 'admin', '', '', 'ai/model/test', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-test', 'core:setting-ai-model-test', 0, 'AI模型连通性测试接口权限'),
|
||||
(6659, 6647, '', '拉取厂商可用模型列表', 'admin', '', '', 'ai/model/remote_models', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-remote-list', 'core:setting-ai-model-remote-list', 0, '拉取厂商可用模型列表接口权限');
|
||||
(6659, 6647, '', '拉取厂商可用模型列表', 'admin', '', '', 'ai/model/remote_models', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-remote-list', 'core:setting-ai-model-remote-list', 0, '拉取厂商可用模型列表接口权限'),
|
||||
(6825, 3460, '', '导入主题', 'admin', '', '', 'theme/import', 'POST', '[]', 1, 1, 0, 1, '', '', 2, '', 0, 'diy-theme-import', 'core:diy-theme-import', 0, '导入主题 ZIP 包');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user