diff --git a/server/application/api/logic/UserLogic.php b/server/application/api/logic/UserLogic.php index 888258bd..d7c1d8cc 100644 --- a/server/application/api/logic/UserLogic.php +++ b/server/application/api/logic/UserLogic.php @@ -353,6 +353,9 @@ class UserLogic extends LogicBase $avatar = download_file($avatar_url, 'uploads/user/avatar/', $file_name); } else { $avatar = 'uploads/user/avatar/' . md5($user_id . $time . rand(10000, 99999)) . '.jpeg'; + if (!check_url_safety($avatar_url)) { + throw new Exception('头像地址不合法'); + } $StorageDriver = new StorageDriver($config); if (!$StorageDriver->fetch($avatar_url, $avatar)) { throw new Exception('头像保存失败:' . $StorageDriver->getError()); @@ -541,4 +544,4 @@ class UserLogic extends LogicBase return $result; } -} \ No newline at end of file +} diff --git a/server/application/api/server/UserServer.php b/server/application/api/server/UserServer.php index 3e8e7a82..8c4ca73a 100644 --- a/server/application/api/server/UserServer.php +++ b/server/application/api/server/UserServer.php @@ -72,6 +72,9 @@ class UserServer $avatar = download_file($avatar_url, 'uploads/user/avatar/', $file_name); } else { $avatar = 'uploads/user/avatar/' . md5($openid . $time) . '.jpeg'; + if (!check_url_safety($avatar_url)) { + throw new Exception('头像地址不合法'); + } $StorageDriver = new StorageDriver($config); if (!$StorageDriver->fetch($avatar_url, $avatar)) { throw new Exception( '头像保存失败:'. $StorageDriver->getError()); @@ -200,6 +203,9 @@ class UserServer $avatar = download_file($avatar_url, 'uploads/user/avatar/', $file_name); } else { $avatar = 'uploads/user/avatar/' . md5($openid . $time) . '.jpeg'; + if (!check_url_safety($avatar_url)) { + throw new Exception('头像地址不合法'); + } $StorageDriver = new StorageDriver($config); if (!$StorageDriver->fetch($avatar_url, $avatar)) { throw new Exception( '头像保存失败:'. $StorageDriver->getError()); @@ -243,4 +249,4 @@ class UserServer return $user_info; } -} \ No newline at end of file +} diff --git a/server/application/common.php b/server/application/common.php index 10b85877..e062f06d 100644 --- a/server/application/common.php +++ b/server/application/common.php @@ -97,6 +97,238 @@ function data_error($msg = '', $data = [], $code = 0, $show = 1) return $result; } +/** + * User: 意象信息科技 lr + * Desc: 判断IP是否为内网/回环/链路本地等不安全地址 + * @param string $ip + * @return bool + */ +function is_private_ip($ip) +{ + if (!is_string($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) { + return true; + } + + // IPv4 + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $long = sprintf('%u', ip2long($ip)); + $inRange = function ($min, $max) use ($long) { + return $long >= $min && $long <= $max; + }; + if ($inRange(0, 16777215)) return true; // 0.0.0.0/8 + if ($inRange(167772160, 184549375)) return true; // 10.0.0.0/8 + if ($inRange(2130706432, 2147483647)) return true; // 127.0.0.0/8 回环 + if ($inRange(2851995648, 2852061183)) return true; // 169.254.0.0/16 链路本地 + if ($inRange(2886729728, 2887778303)) return true; // 172.16.0.0/12 + if ($inRange(3232235520, 3232301055)) return true; // 192.168.0.0/16 + if ($inRange(1681915904, 1686110207)) return true; // 100.64.0.0/10 CGNAT + if ($inRange(3221225472, 3221225727)) return true; // 192.0.0.0/24 + if ($inRange(3221225984, 3221226239)) return true; // 192.0.2.0/24 + if ($inRange(3323068416, 3323199487)) return true; // 198.18.0.0/15 + if ($inRange(3325256704, 3325256959)) return true; // 198.51.100.0/24 + if ($inRange(3405803776, 3405804031)) return true; // 203.0.113.0/24 + if ($inRange(3758096384, 4026531839)) return true; // 224.0.0.0/4 组播 + if ($inRange(4026531840, 4294967295)) return true; // 240.0.0.0/4 保留 + return false; + } + + // IPv6 + $packed = inet_pton($ip); + if (false === $packed) { + return true; + } + $hex = bin2hex($packed); + if ($hex === str_repeat('0', 31) . '1') return true; // ::1 回环 + if ($hex === str_repeat('0', 32)) return true; // :: 未指定 + if (strncmp($hex, 'fc', 2) === 0 || strncmp($hex, 'fd', 2) === 0) return true; // fc00::/7 + if (strncmp($hex, 'fe8', 3) === 0 || strncmp($hex, 'fe9', 3) === 0 + || strncmp($hex, 'fea', 3) === 0 || strncmp($hex, 'feb', 3) === 0) return true; // fe80::/10 + if (strncmp($hex, 'ff', 2) === 0) return true; // ff00::/8 组播 + // ::ffff:0:0/96 IPv4 映射地址 + if (substr($hex, 0, 20) === str_repeat('0', 20) && substr($hex, 20, 4) === 'ffff') { + $v4 = long2ip(hexdec(substr($hex, 24, 8))); + return is_private_ip($v4); + } + return false; +} + +/** + * User: 意象信息科技 lr + * Desc: 校验网络资源地址是否安全(仅允许 http/https 且解析后非内网地址) + * @param string $url + * @return bool + */ +function check_url_safety($url) +{ + if (empty($url) || !is_string($url)) { + return false; + } + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + if (!in_array($scheme, ['http', 'https'], true)) { + return false; + } + $host = parse_url($url, PHP_URL_HOST); + if (empty($host) || !is_string($host)) { + return false; + } + $host = rtrim(trim($host, '[]'), '.'); + if (empty($host)) { + return false; + } + return !empty(safe_resolve_ips($host)); +} + +/** + * User: 意象信息科技 lr + * Desc: 解析域名并返回全部安全的IP(任一解析结果为不安全地址则整体拒绝) + * @param string $host + * @return array + */ +function safe_resolve_ips($host) +{ + if (!is_string($host) || empty($host)) { + return []; + } + // IP 字面量直接判断 + if (filter_var($host, FILTER_VALIDATE_IP)) { + return is_private_ip($host) ? [] : [$host]; + } + // 域名解析后逐个判断 + $ips = gethostbynamel($host); + if (false === $ips || empty($ips)) { + return []; + } + foreach ($ips as $ip) { + if (is_private_ip($ip)) { + return []; + } + } + return $ips; +} + +/** + * User: 意象信息科技 lr + * Desc: 安全HTTP请求(校验URL、固定已解析IP、限时限量、不跟随重定向) + * @param string $url + * @param callable|null $write_callback 响应体逐块回调,返回写入字节数 + * @param bool $head 是否只发起 HEAD 请求 + * @param int $max_size 最大下载字节数 + * @param int $timeout 总超时秒数 + * @return bool + */ +function safe_http_fetch($url, $write_callback = null, $head = false, $max_size = 157286400, $timeout = 30) +{ + if (!check_url_safety($url)) { + return false; + } + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + $host = parse_url($url, PHP_URL_HOST); + if (empty($host) || !is_string($host)) { + return false; + } + $host = rtrim(trim($host, '[]'), '.'); + $ips = safe_resolve_ips($host); + if (empty($ips)) { + return false; + } + $port = (int)parse_url($url, PHP_URL_PORT); + if ($port <= 0) { + $port = ($scheme === 'https') ? 443 : 80; + } + $ch = curl_init(); + if (false === $ch) { + return false; + } + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, max(1, (int)$timeout)); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0); + curl_setopt($ch, CURLOPT_MAXREDIRS, 0); + curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + // 固定已校验的解析结果,缓解 DNS rebinding + $resolve = []; + foreach ($ips as $ip) { + $resolve[] = $host . ':' . $port . ':' . $ip; + } + curl_setopt($ch, CURLOPT_RESOLVE, $resolve); + + $downloaded = 0; + $aborted = false; + if ($head) { + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + } else { + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) use ($write_callback, &$downloaded, $max_size, &$aborted) { + $len = strlen($data); + if ($downloaded + $len > $max_size) { + $aborted = true; + return 0; + } + $downloaded += $len; + if (is_callable($write_callback)) { + return (int)$write_callback($data); + } + return $len; + }); + } + + $result = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if (false === $result || $aborted || !in_array($status, [200, 304], true)) { + return false; + } + return true; +} + +/** + * User: 意象信息科技 lr + * Desc: 安全获取远程文件内容 + * @param string $url + * @param int $max_size 最大下载字节数 + * @param int $timeout 总超时秒数 + * @return bool|string + */ +function safe_get_remote_content($url, $max_size = 157286400, $timeout = 30) +{ + $content = ''; + $ok = safe_http_fetch($url, function ($data) use (&$content) { + $content .= $data; + return strlen($data); + }, false, $max_size, $timeout); + if (!$ok || $content === '') { + return false; + } + return $content; +} + +/** + * User: 意象信息科技 lr + * Desc: 安全读取本地文件或远程文件内容(远程仅允许 http/https) + * @param string $uri + * @return bool|string + */ +function safe_read_file($uri) +{ + if (!is_string($uri) || empty($uri)) { + return false; + } + if (strpos($uri, '://') !== false) { + $scheme = strtolower((string)parse_url($uri, PHP_URL_SCHEME)); + if (!in_array($scheme, ['http', 'https'], true)) { + return false; + } + return safe_get_remote_content($uri); + } + if (!is_file($uri) || !is_readable($uri)) { + return false; + } + $content = file_get_contents($uri); + return false === $content ? false : $content; +} + /** * User: 意象信息科技 lr * Desc: 下载文件 @@ -107,22 +339,24 @@ function data_error($msg = '', $data = [], $code = 0, $show = 1) */ function download_file($url, $save_dir, $file_name) { + if (!check_url_safety($url)) { + return ''; + } if (!file_exists($save_dir)) { mkdir($save_dir, 0775, true); } $file_src = $save_dir . $file_name; file_exists($file_src) && unlink($file_src); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); - $file = curl_exec($ch); - curl_close($ch); - $resource = fopen($file_src, 'a'); - fwrite($resource, $file); - fclose($resource); - if (filesize($file_src) == 0) { - unlink($file_src); + $fp = fopen($file_src, 'wb'); + if (false === $fp) { + return ''; + } + $ok = safe_http_fetch($url, function ($data) use ($fp) { + return fwrite($fp, $data); + }, false, 157286400, 30); + fclose($fp); + if (!$ok || filesize($file_src) == 0) { + file_exists($file_src) && unlink($file_src); return ''; } return $file_src; @@ -553,11 +787,7 @@ function check_file_exists($file) { //远程文件 if ('http' == strtolower(substr($file, 0, 4))) { - - $header = get_headers($file, true); - - return isset($header[0]) && (strpos($header[0], '200') || strpos($header[0], '304')); - + return safe_http_fetch($file, null, true, 157286400, 30); } else { return file_exists($file); @@ -1072,5 +1302,3 @@ function check_is_video($video) : bool return strpos($type, 'video') !== false; } - - diff --git a/server/application/common/logic/QrCodeLogic.php b/server/application/common/logic/QrCodeLogic.php index 37d73642..6caaa7af 100644 --- a/server/application/common/logic/QrCodeLogic.php +++ b/server/application/common/logic/QrCodeLogic.php @@ -131,7 +131,18 @@ class QrCodeLogic extends LogicBase{ $qr_code_logic = new QrCodeLogic(); //获取背景图 - $share_background_img = imagecreatefromstring(file_get_contents($background_img)); + $background_content = safe_read_file($background_img); + if (false === $background_content) { + throw new Exception('背景图读取失败'); + } + $background_size = getimagesizefromstring($background_content); + if (false === $background_size) { + throw new Exception('背景图读取失败'); + } + $share_background_img = imagecreatefromstring($background_content); + if (false === $share_background_img) { + throw new Exception('背景图解析失败'); + } //合成头像 $qr_code_logic->writeImg($share_background_img, $user_avatar, $poster_config['head_pic'],true); @@ -153,7 +164,7 @@ class QrCodeLogic extends LogicBase{ $qr_code_logic->writeText($share_background_img, floatval($goods['min_price']), $poster_config['price']); //合成商品标题 - $goods_name = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], $goods['name'], $poster_config['title']['w'],$poster_config['title']['y'],getimagesize($background_img)); + $goods_name = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], $goods['name'], $poster_config['title']['w'],$poster_config['title']['y'],$background_size); $qr_code_logic->writeText($share_background_img, $goods_name, $poster_config['title']); //合成二维码 @@ -323,7 +334,18 @@ class QrCodeLogic extends LogicBase{ $qr_code_logic = new QrCodeLogic(); //获取背景图 - $share_background_img = imagecreatefromstring(file_get_contents($background_img)); + $background_content = safe_read_file($background_img); + if (false === $background_content) { + throw new Exception('背景图读取失败'); + } + $background_size = getimagesizefromstring($background_content); + if (false === $background_size) { + throw new Exception('背景图读取失败'); + } + $share_background_img = imagecreatefromstring($background_content); + if (false === $share_background_img) { + throw new Exception('背景图解析失败'); + } //合成头像 $qr_code_logic->writeImg($share_background_img, $user_avatar, $poster_config['head_pic'],true); @@ -334,7 +356,7 @@ class QrCodeLogic extends LogicBase{ $notice = '长按识别二维码 >>'; $qr_code_logic->writeText($share_background_img, $notice, $poster_config['notice']); //合成商品标题 - $title = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], '邀请你一起来赚大钱', $poster_config['title']['w'],$poster_config['title']['y'],getimagesize($background_img)); + $title = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], '邀请你一起来赚大钱', $poster_config['title']['w'],$poster_config['title']['y'],$background_size); $qr_code_logic->writeText($share_background_img, $title, $poster_config['title']); //邀请码 $qr_code_logic->writeText($share_background_img, '邀请码 '.$user['distribution_code'], $poster_config['code_text']); @@ -468,7 +490,18 @@ class QrCodeLogic extends LogicBase{ $qr_code_logic = new QrCodeLogic(); //获取背景图 - $share_background_img = imagecreatefromstring(file_get_contents($background_img)); + $background_content = safe_read_file($background_img); + if (false === $background_content) { + throw new Exception('背景图读取失败'); + } + $background_size = getimagesizefromstring($background_content); + if (false === $background_size) { + throw new Exception('背景图读取失败'); + } + $share_background_img = imagecreatefromstring($background_content); + if (false === $share_background_img) { + throw new Exception('背景图解析失败'); + } //合成头像 $qr_code_logic->writeImg($share_background_img, $user_avatar, $poster_config['head_pic'],true); @@ -494,7 +527,7 @@ class QrCodeLogic extends LogicBase{ $qr_code_logic->writeText($share_background_img, $brief_title, $poster_config['brief_title']); //合成商品标题 - $goods_name = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], $bargain_launch['goods_snap']['name'], $poster_config['title']['w'],$poster_config['title']['y'],getimagesize($background_img)); + $goods_name = auto_adapt($poster_config['title']['font_size'], 0, $poster_config['title']['font_face'], $bargain_launch['goods_snap']['name'], $poster_config['title']['w'],$poster_config['title']['y'],$background_size); $qr_code_logic->writeText($share_background_img, $goods_name, $poster_config['title']); //合成二维码 @@ -555,7 +588,14 @@ class QrCodeLogic extends LogicBase{ //写入图片 public function writeImg($poster, $img_uri, $config, $is_rounded = false){ - $pic_img = imagecreatefromstring(file_get_contents($img_uri)); + $img_content = safe_read_file($img_uri); + if (false === $img_content) { + throw new Exception('图片读取失败'); + } + $pic_img = imagecreatefromstring($img_content); + if (false === $pic_img) { + throw new Exception('图片解析失败'); + } $is_rounded?$pic_img = rounded_corner($pic_img):'';//切成圆角返回头像资源 $pic_w = imagesx($pic_img); $pic_h = imagesy($pic_img); @@ -591,4 +631,4 @@ class QrCodeLogic extends LogicBase{ return $poster; } -} \ No newline at end of file +}