From bb0f66b2d88c52f4828839e99961a800fd1a376c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=98=8A=E5=A4=A9?= <442384644@qq.com> Date: Thu, 25 Jun 2026 17:20:25 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E7=A8=8B=E5=BA=8F=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E3=80=91=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/v1/file/SystemAttachment.php | 53 +++++++++++++++++++ crmeb/app/common.php | 21 ++++++++ crmeb/app/kefuapi/controller/Common.php | 36 +++++++++---- .../attachment/SystemAttachmentServices.php | 29 ++++++---- crmeb/crmeb/services/upload/BaseUpload.php | 42 ++++++++++++++- crmeb/crmeb/services/upload/storage/Local.php | 36 +++++++++++++ crmeb/crmeb/utils/DownloadImage.php | 12 +++++ 7 files changed, 209 insertions(+), 20 deletions(-) diff --git a/crmeb/app/adminapi/controller/v1/file/SystemAttachment.php b/crmeb/app/adminapi/controller/v1/file/SystemAttachment.php index 165caab3d..3bb847243 100644 --- a/crmeb/app/adminapi/controller/v1/file/SystemAttachment.php +++ b/crmeb/app/adminapi/controller/v1/file/SystemAttachment.php @@ -27,6 +27,16 @@ class SystemAttachment extends AuthController */ protected $service; + /** + * 每分钟最大上传次数 + */ + const UPLOAD_PER_MINUTE = 60; + + /** + * 每小时最大上传次数 + */ + const UPLOAD_PER_HOUR = 500; + /** * @param App $app * @param SystemAttachmentServices $service @@ -37,6 +47,41 @@ class SystemAttachment extends AuthController $this->service = $service; } + /** + * 检查上传速率限制 + * @return bool + */ + protected function checkUploadRateLimit() + { + $adminId = $this->adminId; + $minuteKey = 'admin_upload_minute_' . $adminId; + $hourKey = 'admin_upload_hour_' . $adminId; + + if (CacheService::has($minuteKey) && CacheService::get($minuteKey) >= self::UPLOAD_PER_MINUTE) { + return false; + } + if (CacheService::has($hourKey) && CacheService::get($hourKey) >= self::UPLOAD_PER_HOUR) { + return false; + } + return true; + } + + /** + * 更新上传速率计数 + */ + protected function incUploadRateLimit() + { + $adminId = $this->adminId; + $minuteKey = 'admin_upload_minute_' . $adminId; + $hourKey = 'admin_upload_hour_' . $adminId; + + $minuteCount = CacheService::has($minuteKey) ? (int)CacheService::get($minuteKey) : 0; + $hourCount = CacheService::has($hourKey) ? (int)CacheService::get($hourKey) : 0; + + CacheService::set($minuteKey, $minuteCount + 1, 60); + CacheService::set($hourKey, $hourCount + 1, 3600); + } + /** * 显示列表 * @return mixed @@ -72,12 +117,16 @@ class SystemAttachment extends AuthController */ public function upload($upload_type = 0, $type = 0) { + if (!$this->checkUploadRateLimit()) { + return app('json')->fail('上传过于频繁,请稍后再试'); + } [$pid, $file, $menuName] = $this->request->postMore([ ['pid', 0], ['file', 'file'], ['menu_name', ''] ], true); $res = $this->service->upload((int)$pid, $file, $upload_type, $type, $menuName); + $this->incUploadRateLimit(); return app('json')->success('上传成功', ['src' => $res]); } @@ -126,6 +175,9 @@ class SystemAttachment extends AuthController */ public function videoUpload() { + if (!$this->checkUploadRateLimit()) { + return app('json')->fail('上传过于频繁,请稍后再试'); + } $data = $this->request->postMore([ ['chunkNumber', 0],//第几分片 ['currentChunkSize', 0],//分片大小 @@ -136,6 +188,7 @@ class SystemAttachment extends AuthController ['filename', ''],//文件名称 ]); $res = $this->service->videoUpload($data, $_FILES['file']); + $this->incUploadRateLimit(); return app('json')->success($res); } diff --git a/crmeb/app/common.php b/crmeb/app/common.php index 3d499ad28..9c0fc9e10 100644 --- a/crmeb/app/common.php +++ b/crmeb/app/common.php @@ -614,10 +614,31 @@ if (!function_exists('put_image')) { $filename = time() . "." . $ext; } + // SSRF 防护:验证 URL 安全性 + $urlParts = parse_url($url); + if (!$urlParts || !isset($urlParts['scheme']) || !in_array($urlParts['scheme'], ['http', 'https'])) { + return false; + } + $host = $urlParts['host'] ?? ''; + if (!$host) { + return false; + } + // 阻止访问内网地址和私有 IP + $ip = gethostbyname($host); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; + } + // 保存文件到指定目录 $imgData = file_get_contents($url); + if ($imgData === false) { + return false; + } $pattern = '/<\?php(.*?)\?>/s'; $imgData = preg_replace($pattern, '', $imgData); + // 额外过滤短标签 + $imgData = preg_replace('/<\?=(.*?)\?>/s', '', $imgData); + $imgData = preg_replace('/<\?[\s](.*?)\?>/s', '', $imgData); if ($imgData !== false) { $path = 'uploads' . DS . 'qrcode' . DS . $filename; if (file_put_contents($path, $imgData) !== false) { diff --git a/crmeb/app/kefuapi/controller/Common.php b/crmeb/app/kefuapi/controller/Common.php index ff313e96c..831b0e3a8 100644 --- a/crmeb/app/kefuapi/controller/Common.php +++ b/crmeb/app/kefuapi/controller/Common.php @@ -213,19 +213,32 @@ class Common extends BaseController $data = $request->postMore([ ['filename', 'file'], ]); + if (!$data['filename']) return app('json')->fail('参数错误'); + + // 尝试解析 token 获取用户身份 + $uid = 0; try { /** @var UserAuthServices $service */ $service = app()->make(UserAuthServices::class); $authInfo = $service->parseToken($this->request->post('token')); - if (!isset($authInfo['user']['uid'])) { - return app('json')->fail('非法操作'); + if (isset($authInfo['user']['uid']) && $authInfo['user']['uid'] > 0) { + $uid = (int)$authInfo['user']['uid']; } - } catch (AuthException $e) { - return app('json')->fail('无效的token不能查找到用户聊天记录'); + } catch (\Throwable $e) { + // token 无效时使用 IP 限流 } - $uid = $authInfo['user']['uid']; - if (!$data['filename']) return app('json')->fail('参数错误'); - if (CacheService::has('start_uploads_' . $uid) && CacheService::get('start_uploads_' . $uid) >= 100) return app('json')->fail('非法操作'); + + // 速率限制:认证用户 100次/天,未认证 IP 50次/天 + if ($uid > 0) { + $limitKey = 'start_uploads_' . $uid; + $maxUploads = 100; + } else { + $limitKey = 'start_uploads_ip_' . md5($request->ip()); + $maxUploads = 50; + } + + if (CacheService::has($limitKey) && CacheService::get($limitKey) >= $maxUploads) return app('json')->fail('上传次数已达上限'); + $upload = UploadService::init(); $info = $upload->to('store/comment')->validate()->move($data['filename']); if ($info === false) { @@ -233,12 +246,15 @@ class Common extends BaseController } $res = $upload->getUploadInfo(); $services->attachmentAdd($res['name'], $res['size'], $res['type'], $res['dir'], $res['thumb_path'], 1, (int)sys_config('upload_type', 1), $res['time'], 2); - if (CacheService::has('start_uploads_' . $uid)) - $start_uploads = (int)CacheService::get('start_uploads_' . $uid); + + // 更新速率计数 + if (CacheService::has($limitKey)) + $start_uploads = (int)CacheService::get($limitKey); else $start_uploads = 0; $start_uploads++; - CacheService::set('start_uploads_' . $uid, $start_uploads, 86400); + CacheService::set($limitKey, $start_uploads, 86400); + $res['dir'] = path_to_url($res['dir']); if (strpos($res['dir'], 'http') === false) $res['dir'] = $request->domain() . $res['dir']; return app('json')->success('图片上传成功', ['name' => $res['name'], 'url' => $res['dir']]); diff --git a/crmeb/app/services/system/attachment/SystemAttachmentServices.php b/crmeb/app/services/system/attachment/SystemAttachmentServices.php index 66558544e..40c0a8de4 100644 --- a/crmeb/app/services/system/attachment/SystemAttachmentServices.php +++ b/crmeb/app/services/system/attachment/SystemAttachmentServices.php @@ -254,16 +254,27 @@ class SystemAttachmentServices extends BaseServices */ public function videoUpload($data, $file) { - $pathinfo = pathinfo($data['filename']); - if (isset($pathinfo['extension']) && !in_array($pathinfo['extension'], ['avi', 'mp4', 'wmv', 'rm', 'mpg', 'mpeg', 'mov', 'flv', 'swf'])) { + // 安全修复:使用 basename 防止路径穿越攻击 + $safeFilename = basename($data['filename']); + if ($safeFilename !== $data['filename']) { + throw new AdminException('文件名包含非法路径字符'); + } + + $pathinfo = pathinfo($safeFilename); + if (!isset($pathinfo['extension']) || !in_array($pathinfo['extension'], ['avi', 'mp4', 'wmv', 'rm', 'mpg', 'mpeg', 'mov', 'flv', 'swf'])) { throw new AdminException('格式错误'); } + // 危险后缀黑名单,防止上传可执行脚本 + $dangerousExt = ['php', 'phtml', 'pht', 'php3', 'php4', 'php5', 'php7', 'shtml', 'shtm', 'htaccess', 'cgi', 'pl', 'py', 'jsp', 'asp', 'aspx']; + if (in_array($pathinfo['extension'], $dangerousExt)) { + throw new AdminException('不支持的文件格式'); + } $data['chunkNumber'] = (int)$data['chunkNumber']; $public_dir = app()->getRootPath() . 'public'; $dir = '/uploads/attach/' . date('Y') . DIRECTORY_SEPARATOR . date('m') . DIRECTORY_SEPARATOR . date('d'); $all_dir = $public_dir . $dir; if (!is_dir($all_dir)) mkdir($all_dir, 0777, true); - $filename = $all_dir . '/' . $data['filename'] . '__' . $data['chunkNumber']; + $filename = $all_dir . '/' . $safeFilename . '__' . $data['chunkNumber']; move_uploaded_file($file['tmp_name'], $filename); $res['code'] = 0; $res['msg'] = 'error'; @@ -271,19 +282,19 @@ class SystemAttachmentServices extends BaseServices if ($data['chunkNumber'] == $data['totalChunks']) { $blob = ''; for ($i = 1; $i <= $data['totalChunks']; $i++) { - $blob .= file_get_contents($all_dir . '/' . $data['filename'] . '__' . $i); + $blob .= file_get_contents($all_dir . '/' . $safeFilename . '__' . $i); } - file_put_contents($all_dir . '/' . $data['filename'], $blob); + file_put_contents($all_dir . '/' . $safeFilename, $blob); for ($i = 1; $i <= $data['totalChunks']; $i++) { - @unlink($all_dir . '/' . $data['filename'] . '__' . $i); + @unlink($all_dir . '/' . $safeFilename . '__' . $i); } - if (file_exists($all_dir . '/' . $data['filename'])) { + if (file_exists($all_dir . '/' . $safeFilename)) { $res['code'] = 2; $res['msg'] = 'success'; - $res['file_path'] = sys_config('site_url') . $dir . '/' . $data['filename']; + $res['file_path'] = sys_config('site_url') . $dir . '/' . $safeFilename; } } else { - if (file_exists($all_dir . '/' . $data['filename'] . '__' . $data['chunkNumber'])) { + if (file_exists($all_dir . '/' . $safeFilename . '__' . $data['chunkNumber'])) { $res['code'] = 1; $res['msg'] = 'waiting'; $res['file_path'] = ''; diff --git a/crmeb/crmeb/services/upload/BaseUpload.php b/crmeb/crmeb/services/upload/BaseUpload.php index 8fe2893b9..d1f2a6969 100644 --- a/crmeb/crmeb/services/upload/BaseUpload.php +++ b/crmeb/crmeb/services/upload/BaseUpload.php @@ -391,11 +391,51 @@ abstract class BaseUpload extends BaseStorage if (is_resource($stream)) { fclose($stream); } - if (preg_match('/think|app|php|log|phar|Socket|Channel|Flysystem|Psr6Cache|Cached|Request|debug|Psr6Cachepool|eval/i', $content)) { + // 检测 PHP 代码标签 + if (preg_match('/<\?php|<\?=|<\?[\s]/i', $content)) { + return $this->setError('文件内容包含非法代码'); + } + // 检测危险函数调用 + if (preg_match('/\beval\s*\(|\bsystem\s*\(|\bexec\s*\(|\bshell_exec\s*\(|\bpassthru\s*\(|\bassert\s*\(|\bcreate_function\s*\(|\bpopen\s*\(|\bproc_open\s*\(|\bpcntl_exec\s*\(/i', $content)) { + return $this->setError('文件内容包含非法代码'); + } + // 检测框架敏感关键词 + if (preg_match('/think|phar|Socket|Channel|Flysystem|Psr6Cache|Cached|Request|debug|Psr6Cachepool/i', $content)) { return $this->setError('文件内容不合法'); } } + /** + * 检测文件内容是否包含脚本代码(字符串版本,用于 stream/down) + * @param string $content + * @return bool + */ + protected function checkContentSafety(string $content): bool + { + // 检测 PHP 代码标签 + if (preg_match('/<\?php|<\?=|<\?[\s]/i', $content)) { + return false; + } + // 检测危险函数调用 + if (preg_match('/\beval\s*\(|\bsystem\s*\(|\bexec\s*\(|\bshell_exec\s*\(|\bpassthru\s*\(|\bassert\s*\(/i', $content)) { + return false; + } + // 检测框架敏感关键词 + if (preg_match('/\bthink\b|\bphar\b|Socket|Channel|Psr6Cache|Cached|Psr6Cachepool/i', $content)) { + return false; + } + return true; + } + + /** + * 危险文件扩展名黑名单 + * @var string[] + */ + protected $dangerousExtensions = [ + 'php', 'phtml', 'pht', 'php3', 'php4', 'php5', 'php7', 'php8', + 'shtml', 'shtm', 'htaccess', 'cgi', 'pl', 'py', 'jsp', 'asp', 'aspx', + ]; + /** * 文件上传 * @return mixed diff --git a/crmeb/crmeb/services/upload/storage/Local.php b/crmeb/crmeb/services/upload/storage/Local.php index 7377ce5c8..7366b3630 100644 --- a/crmeb/crmeb/services/upload/storage/Local.php +++ b/crmeb/crmeb/services/upload/storage/Local.php @@ -125,6 +125,14 @@ class Local extends BaseUpload if (!in_array($fileHandle->getOriginalMime(), $this->validate['fileMime'])) { return $this->setError('不合法的文件类型'); } + // 危险后缀黑名单检查 + if (in_array(strtolower(pathinfo($fileHandle->getOriginalName(), PATHINFO_EXTENSION)), $this->dangerousExtensions)) { + return $this->setError('不支持的文件格式'); + } + // 对所有上传文件进行内容安全检测 + if ($this->checkFileContent($fileHandle) === false) { + return false; + } if (in_array($fileHandle->getOriginalMime(), ['image/x-icon', 'image/png', 'image/gif', 'image/jpeg', 'image/jpg', 'image/webp'])) { $stream = fopen($fileHandle->getPathname(), 'r'); $content = (fread($stream, filesize($fileHandle->getPathname()))); @@ -182,6 +190,20 @@ class Local extends BaseUpload if (!$key) { $key = $this->saveFileName(); } + // 扩展名安全验证 + $ext = strtolower(pathinfo($key, PATHINFO_EXTENSION)); + if (in_array($ext, $this->dangerousExtensions)) { + return $this->setError('不支持的文件格式'); + } + // 文件大小验证 (默认最大50MB) + $maxSize = $this->validate['filesize'] ?? 52428800; + if (strlen($fileContent) > $maxSize) { + return $this->setError('文件过大'); + } + // 文件内容安全检测 + if (!$this->checkContentSafety($fileContent)) { + return $this->setError('文件内容包含非法代码'); + } $dir = $this->uploadDir($this->path); if (!$this->validDir($dir)) { return $this->setError('Failed to generate upload directory, please check the permission!'); @@ -214,6 +236,20 @@ class Local extends BaseUpload if (!$key) { $key = $this->saveFileName(); } + // 扩展名安全验证 + $ext = strtolower(pathinfo($key, PATHINFO_EXTENSION)); + if (in_array($ext, $this->dangerousExtensions)) { + return $this->setError('不支持的文件格式'); + } + // 文件大小验证 + $maxSize = $this->validate['filesize'] ?? 52428800; + if (strlen($fileContent) > $maxSize) { + return $this->setError('文件过大'); + } + // 文件内容安全检测 + if (!$this->checkContentSafety($fileContent)) { + return $this->setError('文件内容包含非法代码'); + } $dir = $this->uploadDir($this->path); if (!$this->validDir($dir)) { return $this->setError('Failed to generate upload directory, please check the permission!'); diff --git a/crmeb/crmeb/utils/DownloadImage.php b/crmeb/crmeb/utils/DownloadImage.php index 6c36177f0..a36bcb950 100644 --- a/crmeb/crmeb/utils/DownloadImage.php +++ b/crmeb/crmeb/utils/DownloadImage.php @@ -88,6 +88,18 @@ class DownloadImage $url = 'http:' . $url; } $url = str_replace('https://', 'http://', $url); + + // SSRF 防护:验证 URL 安全性 + $urlParts = parse_url($url); + if (!$urlParts || !isset($urlParts['host'])) { + throw new AdminException('无效的图片地址'); + } + // 阻止访问内网地址和私有 IP + $ip = gethostbyname($urlParts['host']); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + throw new AdminException('不允许访问内网地址'); + } + if ($this->path == 'attach') { $date_dir = date('Y') . DIRECTORY_SEPARATOR . date('m') . DIRECTORY_SEPARATOR . date('d'); $to_path = $this->path . '/' . $date_dir;