【程序目录】更新

This commit is contained in:
吴昊天 2026-06-25 17:20:25 +08:00
parent 0a5aa8bce3
commit bb0f66b2d8
7 changed files with 209 additions and 20 deletions

View File

@ -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);
}

View File

@ -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) {

View File

@ -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']]);

View File

@ -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'] = '';

View File

@ -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

View File

@ -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!');

View File

@ -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;