fix(admin): 强化上传文件安全校验

同步统一上传安全服务,校验相对路径、文件后缀及图片内容,并在写入存储前拒绝不安全文件;存储配置复用同一后缀检查规则。
This commit is contained in:
Anyon 2026-09-07 17:12:37 +08:00
parent cf9bbe0232
commit 9d064cc01f
3 changed files with 157 additions and 48 deletions

View File

@ -20,6 +20,7 @@ declare(strict_types=1);
namespace app\admin\controller;
use app\admin\service\UploadSecurity;
use think\admin\Controller;
use think\admin\Plugin;
use think\admin\service\AdminService;
@ -140,10 +141,11 @@ class Config extends Controller
} else {
$post = $this->request->post();
if (!empty($post['storage']['allow_exts'])) {
$deny = ['sh', 'asp', 'bat', 'cmd', 'exe', 'php'];
$exts = array_unique(str2arr(strtolower($post['storage']['allow_exts'])));
if (count(array_intersect($deny, $exts)) > 0) {
$this->error('禁止上传可执行的文件!');
foreach ($exts as $extension) {
if (!UploadSecurity::isExtensionSafe($extension)) {
$this->error('禁止上传可执行的文件!');
}
}
$post['storage']['allow_exts'] = join(',', $exts);
}

View File

@ -20,6 +20,7 @@ declare(strict_types=1);
namespace app\admin\controller\api;
use app\admin\service\UploadSecurity;
use think\admin\Controller;
use think\admin\helper\QueryHelper;
use think\admin\model\SystemFile;
@ -97,7 +98,8 @@ class Upload extends Controller
try {
[$uuid, $unid] = $this->initUnid();
[$name, $safe] = [input('name'), $this->getSafe()];
$data = ['uptype' => $this->getType(), 'safe' => intval($safe), 'key' => input('key')];
$key = input('key', '');
$data = ['uptype' => $this->getType(), 'safe' => intval($safe), 'key' => is_string($key) ? $key : ''];
$file = SystemFile::mk()->data($this->_vali([
'xkey.value' => $data['key'],
'type.value' => $this->getType(),
@ -114,6 +116,14 @@ class Upload extends Controller
if (empty($mime)) {
$file->setAttr('mime', Storage::mime($file->getAttr('xext')));
}
$extension = $file->getAttr('xext');
$extension = is_string($extension) ? strtolower($extension) : '';
if (!UploadSecurity::isNameSafe($data['key'], $extension)) {
$this->error('文件路径或后缀异常,请重新上传文件!');
}
if (!UploadSecurity::isExtensionSafe($extension)) {
$this->error('文件安全保护,禁止上传可执行文件!');
}
$info = Storage::instance($data['uptype'])->info($data['key'], $safe, $name);
if (isset($info['url'], $info['key'])) {
$file->save(['xurl' => $info['url'], 'isfast' => 1, 'issafe' => $data['safe']]);
@ -204,14 +214,14 @@ class Upload extends Controller
// 开始处理文件上传
$file = $this->getFile();
$extension = strtolower($file->getOriginalExtension());
$saveFileName = input('key') ?: Storage::name($file->getPathname(), $extension, '', 'md5_file');
// 检查文件名称是否合法
if (strpos($saveFileName, '..') !== false) {
$this->error('文件路径不能出现跳级操作!');
$key = input('key', '');
if (!is_string($key) && $key !== '') {
$this->error('文件路径或后缀异常,请重新上传文件!');
}
// 检查文件后缀是否被恶意修改
if (strtolower(pathinfo(parse_url($saveFileName, PHP_URL_PATH), PATHINFO_EXTENSION)) !== $extension) {
$this->error('文件后缀异常,请重新上传文件!');
$saveFileName = is_string($key) ? $key : '';
$saveFileName = $saveFileName ?: Storage::name($file->getPathname(), $extension, '', 'md5_file');
if (!UploadSecurity::isNameSafe($saveFileName, $extension)) {
$this->error('文件路径或后缀异常,请重新上传文件!');
}
// 屏蔽禁止上传指定后缀的文件
if (!in_array($extension, str2arr(sysconf('storage.allow_exts|raw')))) {
@ -221,11 +231,17 @@ class Upload extends Controller
if (empty($uuid) && $unid > 0 && !in_array($extension, $unexts)) {
$this->error('文件类型受限,请上传允许的文件类型!');
}
if (in_array($extension, ['sh', 'asp', 'bat', 'cmd', 'exe', 'php'])) {
if (!UploadSecurity::isExtensionSafe($extension)) {
$this->error('文件安全保护,禁止上传可执行文件!');
}
try {
$safeMode = $this->getSafe();
if (in_array($extension, ['jpg', 'gif', 'png', 'bmp', 'jpeg', 'wbmp'])) {
$imageSize = @getimagesize($file->getPathname());
if (!UploadSecurity::isImageSafe($file->getPathname()) || $imageSize === false || $imageSize[0] < 1 || $imageSize[1] < 1) {
$this->error('图片未通过安全检查!');
}
}
if (($type = $this->getType()) === 'local') {
$local = LocalStorage::instance();
$distName = $local->path($saveFileName, $safeMode);
@ -236,15 +252,6 @@ class Upload extends Controller
$file->move(dirname($distName), basename($distName));
}
$info = $local->info($saveFileName, $safeMode, $file->getOriginalName());
if (in_array($extension, ['jpg', 'gif', 'png', 'bmp', 'jpeg', 'wbmp'])) {
if ($this->imgNotSafe($distName) && $local->del($saveFileName)) {
$this->error('图片未通过安全检查!');
}
[$width, $height] = getimagesize($distName);
if (($width < 1 || $height < 1) && $local->del($saveFileName)) {
$this->error('读取图片的尺寸失败!');
}
}
} else {
$bina = file_get_contents($file->getPathname());
$info = Storage::instance($type)->set($saveFileName, $bina, $safeMode, $file->getOriginalName());
@ -316,31 +323,4 @@ class Upload extends Controller
return [$uuid, $unid, $exts];
}
}
/**
* 检查图片是否安全.
*/
private function imgNotSafe(string $filename): bool
{
$source = fopen($filename, 'rb');
if (($size = filesize($filename)) > 512) {
$hexs = bin2hex(fread($source, 512));
fseek($source, $size - 512);
$hexs .= bin2hex(fread($source, 512));
} else {
$hexs = bin2hex(fread($source, $size));
}
if (is_resource($source)) {
fclose($source);
}
$bins = hex2bin($hexs);
/* 匹配十六进制中的 <% ( ) %> 或 <? ( ) ?> 或 <script | /script> */
foreach (['<?php ', '<% ', '<script '] as $key) {
if (stripos($bins, $key) !== false) {
return true;
}
}
$result = preg_match('/(3c25.*?28.*?29.*?253e)|(3c3f.*?28.*?29.*?3f3e)|(3C534352495054)|(2F5343524950543E)|(3C736372697074)|(2F7363726970743E)/is', $hexs);
return $result === false || $result > 0;
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | ThinkAdmin Plugin for ThinkAdmin
* +----------------------------------------------------------------------
* | 版权所有 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | 官方网站: https://thinkadmin.top
* +----------------------------------------------------------------------
* | 开源协议 ( https://mit-license.org )
* | 免责声明 ( https://thinkadmin.top/disclaimer )
* | 会员特权 ( https://thinkadmin.top/vip-introduce )
* +----------------------------------------------------------------------
* | gitee 代码仓库https://gitee.com/zoujingli/ThinkAdmin
* | github 代码仓库https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace app\admin\service;
use think\admin\Storage;
/**
* 上传文件安全检查.
* @class UploadSecurity
*/
final class UploadSecurity
{
/**
* 可能被 Web 服务器、脚本解释器或操作系统执行的后缀.
*/
private const EXECUTABLE_EXTENSIONS = [
'asp', 'aspx', 'asa', 'asax', 'ashx', 'asmx',
'bat', 'bash', 'cgi', 'cmd', 'com', 'dll', 'dylib', 'exe',
'jar', 'jsp', 'jspx', 'msi', 'pht', 'phtm', 'phtml', 'phar',
'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phps',
'pl', 'py', 'rb', 'sh', 'shtm', 'shtml', 'so', 'zsh',
];
/**
* 检查上传键是否为规范的相对文件路径.
*/
public static function isNameSafe(string $name, string $extension): bool
{
$extension = strtolower(trim($extension, ". \\/\\\t\n\r\0\x0B"));
if ($extension === '' || !self::isPathSafe($name)) {
return false;
}
return strtolower(pathinfo($name, PATHINFO_EXTENSION)) === $extension;
}
/**
* 检查文件后缀是否允许存储.
*/
public static function isExtensionSafe(string $extension): bool
{
$extension = strtolower(trim($extension, ". \\/\\\t\n\r\0\x0B"));
if (preg_match('/^php\d*$/D', $extension)) {
return false;
}
return $extension !== '' && !in_array($extension, self::EXECUTABLE_EXTENSIONS, true);
}
/**
* 流式检查图片中是否夹带服务端脚本.
*/
public static function isImageSafe(string $filename): bool
{
if (!is_file($filename) || !is_readable($filename)) {
return false;
}
if (!is_resource($stream = @fopen($filename, 'rb'))) {
return false;
}
$carry = '';
try {
while (!feof($stream)) {
$chunk = fread($stream, 8192);
if ($chunk === false) {
return false;
}
$buffer = $carry . $chunk;
if (self::containsScript($buffer)) {
return false;
}
$carry = substr($buffer, -4096);
}
return true;
} finally {
fclose($stream);
}
}
/**
* Backward-compatible path validation for older ThinkLibrary releases.
*/
private static function isPathSafe(string $name): bool
{
if (method_exists(Storage::class, 'isPathSafe')) {
return Storage::isPathSafe($name);
}
if ($name === '' || strlen($name) > 1024 || preg_match('/[\x00-\x1F\x7F%?#\\\\\\\]/', $name)) {
return false;
}
return preg_match('#^(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.[A-Za-z0-9]+$#D', $name) === 1;
}
/**
* 检查服务端脚本起始标签.
*/
private static function containsScript(string $content): bool
{
if (stripos($content, '<script') !== false) {
return true;
}
$phpTag = preg_match('/<\?(?!xml\b)/i', $content);
if ($phpTag === false || $phpTag > 0) {
return true;
}
if (strpos($content, '<%') !== false) {
return true;
}
return false;
}
}