mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
381 lines
14 KiB
PHP
381 lines
14 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file system/App/Common/OutboundHttpClient.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Common;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
/** DNS-pinned HTTP client for server-side requests to untrusted URLs. */
|
|
class OutboundHttpClient
|
|
{
|
|
const DEFAULT_MAX_BYTES = 2097152;
|
|
protected static $dnsCache = array();
|
|
|
|
public static function get($url, array $options = array())
|
|
{
|
|
return self::request('GET', $url, null, $options);
|
|
}
|
|
|
|
public static function post($url, $body, array $options = array())
|
|
{
|
|
return self::request('POST', $url, $body, $options);
|
|
}
|
|
|
|
public static function inspectUrl($url, array $options = array())
|
|
{
|
|
$options = self::options($options);
|
|
return self::resolve((string) $url, $options);
|
|
}
|
|
|
|
public static function isPublicIp($ip)
|
|
{
|
|
return filter_var(
|
|
(string) $ip,
|
|
FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
|
) !== false;
|
|
}
|
|
|
|
protected static function request($method, $url, $body, array $options)
|
|
{
|
|
if (!function_exists('curl_init')) {
|
|
throw new \RuntimeException('Для исходящих HTTP-запросов требуется расширение cURL');
|
|
}
|
|
|
|
$options = self::options($options);
|
|
$method = strtoupper((string) $method);
|
|
if (!in_array($method, array('GET', 'POST'), true)) {
|
|
throw new \InvalidArgumentException('Неподдерживаемый метод исходящего HTTP-запроса');
|
|
}
|
|
|
|
$currentUrl = (string) $url;
|
|
$currentMethod = $method;
|
|
$currentBody = $body;
|
|
$currentHeaders = $options['headers'];
|
|
for ($redirect = 0; $redirect <= $options['max_redirects']; $redirect++) {
|
|
$target = self::resolve($currentUrl, $options);
|
|
$response = self::execute($currentMethod, $target, $currentBody, $currentHeaders, $options);
|
|
if ($response['status'] < 300 || $response['status'] >= 400) {
|
|
if ($response['status'] < 200 || $response['status'] >= 300) {
|
|
throw new \RuntimeException('Удалённый сервер вернул HTTP ' . $response['status']);
|
|
}
|
|
|
|
$response['url'] = $target['url'];
|
|
return $response;
|
|
}
|
|
|
|
if ($redirect >= $options['max_redirects'] || empty($response['headers']['location'])) {
|
|
throw new \RuntimeException('Удалённый сервер вернул запрещённое перенаправление');
|
|
}
|
|
|
|
$nextUrl = self::redirectUrl($target['url'], (string) $response['headers']['location']);
|
|
$nextTarget = self::resolve($nextUrl, $options);
|
|
if (self::origin($target) !== self::origin($nextTarget)) {
|
|
$currentHeaders = self::withoutSensitiveHeaders($currentHeaders);
|
|
}
|
|
$currentUrl = $nextTarget['url'];
|
|
if ($response['status'] === 303 || (($response['status'] === 301 || $response['status'] === 302) && $currentMethod === 'POST')) {
|
|
$currentMethod = 'GET';
|
|
$currentBody = null;
|
|
}
|
|
}
|
|
|
|
throw new \RuntimeException('Превышен лимит перенаправлений');
|
|
}
|
|
|
|
protected static function execute($method, array $target, $body, array $headers, array $options)
|
|
{
|
|
$buffer = '';
|
|
$bytes = 0;
|
|
$overflow = false;
|
|
$responseHeaders = array();
|
|
$headerOverflow = false;
|
|
$handle = curl_init($target['url']);
|
|
$resolveIp = strpos($target['ip'], ':') !== false ? '[' . $target['ip'] . ']' : $target['ip'];
|
|
$curlOptions = array(
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_CONNECTTIMEOUT => $options['connect_timeout'],
|
|
CURLOPT_TIMEOUT => $options['timeout'],
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_FAILONERROR => false,
|
|
CURLOPT_ENCODING => '',
|
|
CURLOPT_NOSIGNAL => true,
|
|
CURLOPT_PROXY => '',
|
|
CURLOPT_USERAGENT => $options['user_agent'],
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_RESOLVE => array($target['host'] . ':' . $target['port'] . ':' . $resolveIp),
|
|
CURLOPT_HEADERFUNCTION => function ($curl, $line) use (&$responseHeaders, &$headerOverflow, $options) {
|
|
$length = strlen($line);
|
|
if (stripos($line, 'HTTP/') === 0) {
|
|
$responseHeaders = array();
|
|
return $length;
|
|
}
|
|
|
|
$position = strpos($line, ':');
|
|
if ($position === false) {
|
|
return $length;
|
|
}
|
|
|
|
$name = strtolower(trim(substr($line, 0, $position)));
|
|
$value = trim(substr($line, $position + 1));
|
|
if ($name === 'content-length' && ctype_digit($value) && (int) $value > $options['max_bytes']) {
|
|
$headerOverflow = true;
|
|
return 0;
|
|
}
|
|
$responseHeaders[$name] = $value;
|
|
return $length;
|
|
},
|
|
CURLOPT_WRITEFUNCTION => function ($curl, $chunk) use (&$buffer, &$bytes, &$overflow, $options) {
|
|
return self::appendChunk($buffer, $bytes, $overflow, $chunk, $options['max_bytes']);
|
|
},
|
|
);
|
|
if (defined('CURLOPT_PROTOCOLS')) {
|
|
$protocols = 0;
|
|
if (in_array('http', $options['allowed_schemes'], true)) { $protocols |= CURLPROTO_HTTP; }
|
|
if (in_array('https', $options['allowed_schemes'], true)) { $protocols |= CURLPROTO_HTTPS; }
|
|
$curlOptions[CURLOPT_PROTOCOLS] = $protocols;
|
|
}
|
|
if ($method === 'POST') {
|
|
$curlOptions[CURLOPT_POST] = true;
|
|
$curlOptions[CURLOPT_POSTFIELDS] = (string) $body;
|
|
}
|
|
|
|
curl_setopt_array($handle, $curlOptions);
|
|
$result = curl_exec($handle);
|
|
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
|
$primaryIp = (string) curl_getinfo($handle, CURLINFO_PRIMARY_IP);
|
|
$error = curl_error($handle);
|
|
curl_close($handle);
|
|
if ($overflow || $headerOverflow) {
|
|
throw new \RuntimeException('Удалённый ответ превышает допустимый размер');
|
|
}
|
|
if ($result === false) {
|
|
throw new \RuntimeException('Не удалось получить удалённый ответ' . ($error !== '' ? ': ' . $error : ''));
|
|
}
|
|
if (!self::sameIp($primaryIp, $target['ip'])) {
|
|
throw new \RuntimeException('Удалённый сервер изменил проверенный сетевой адрес');
|
|
}
|
|
|
|
return array('status' => $status, 'headers' => $responseHeaders, 'body' => $buffer, 'bytes' => $bytes);
|
|
}
|
|
|
|
protected static function resolve($url, array $options)
|
|
{
|
|
$url = trim((string) $url);
|
|
if ($url === '' || strlen($url) > 2048 || preg_match('/[\x00-\x20\x7f]/', $url)) {
|
|
throw new \InvalidArgumentException('Некорректный URL исходящего запроса');
|
|
}
|
|
|
|
$parts = parse_url($url);
|
|
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) {
|
|
throw new \InvalidArgumentException('Некорректный URL исходящего запроса');
|
|
}
|
|
|
|
$scheme = strtolower((string) $parts['scheme']);
|
|
if (!in_array($scheme, $options['allowed_schemes'], true)) {
|
|
throw new \InvalidArgumentException('Схема удалённого URL запрещена');
|
|
}
|
|
|
|
$host = self::asciiHost((string) $parts['host']);
|
|
$port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80);
|
|
if (!in_array($port, $options['allowed_ports'], true)) {
|
|
throw new \InvalidArgumentException('Порт удалённого URL запрещён');
|
|
}
|
|
|
|
$ips = self::hostAddresses($host);
|
|
if (!$ips) {
|
|
throw new \RuntimeException('Удалённый хост не имеет доступного адреса');
|
|
}
|
|
foreach ($ips as $ip) {
|
|
if (!self::isPublicIp($ip)) {
|
|
throw new \RuntimeException('Удалённый хост указывает на private или reserved сеть');
|
|
}
|
|
}
|
|
|
|
$path = isset($parts['path']) && $parts['path'] !== '' ? (string) $parts['path'] : '/';
|
|
$query = isset($parts['query']) && $parts['query'] !== '' ? '?' . (string) $parts['query'] : '';
|
|
$hostForUrl = strpos($host, ':') !== false ? '[' . $host . ']' : $host;
|
|
$portPart = ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80) ? '' : ':' . $port;
|
|
return array(
|
|
'url' => $scheme . '://' . $hostForUrl . $portPart . $path . $query,
|
|
'scheme' => $scheme,
|
|
'host' => $host,
|
|
'port' => $port,
|
|
'ip' => $ips[0],
|
|
);
|
|
}
|
|
|
|
protected static function hostAddresses($host)
|
|
{
|
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
|
return array($host);
|
|
}
|
|
if (isset(self::$dnsCache[$host])) {
|
|
return self::$dnsCache[$host];
|
|
}
|
|
|
|
$addresses = array();
|
|
if (function_exists('dns_get_record')) {
|
|
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
|
foreach (is_array($records) ? $records : array() as $record) {
|
|
if (!empty($record['ip'])) { $addresses[] = (string) $record['ip']; }
|
|
if (!empty($record['ipv6'])) { $addresses[] = (string) $record['ipv6']; }
|
|
}
|
|
}
|
|
if (!$addresses) {
|
|
$ipv4 = @gethostbynamel($host);
|
|
$addresses = is_array($ipv4) ? $ipv4 : array();
|
|
}
|
|
|
|
$addresses = array_values(array_unique(array_filter($addresses, function ($ip) {
|
|
return filter_var($ip, FILTER_VALIDATE_IP) !== false;
|
|
})));
|
|
usort($addresses, function ($first, $second) {
|
|
return (strpos($first, ':') === false ? 0 : 1) - (strpos($second, ':') === false ? 0 : 1);
|
|
});
|
|
self::$dnsCache[$host] = $addresses;
|
|
return $addresses;
|
|
}
|
|
|
|
protected static function appendChunk(&$buffer, &$bytes, &$overflow, $chunk, $maxBytes)
|
|
{
|
|
$length = strlen((string) $chunk);
|
|
if ((int) $bytes + $length > (int) $maxBytes) {
|
|
$overflow = true;
|
|
return 0;
|
|
}
|
|
|
|
$bytes += $length;
|
|
$buffer .= $chunk;
|
|
return $length;
|
|
}
|
|
|
|
protected static function asciiHost($host)
|
|
{
|
|
$host = strtolower(rtrim(trim((string) $host), '.'));
|
|
if ($host === '' || strlen($host) > 253) {
|
|
throw new \InvalidArgumentException('Некорректный хост удалённого URL');
|
|
}
|
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
|
return $host;
|
|
}
|
|
if (preg_match('/[^\x20-\x7e]/', $host)) {
|
|
if (!function_exists('idn_to_ascii')) {
|
|
throw new \InvalidArgumentException('IDN-хост не поддерживается сервером');
|
|
}
|
|
$variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0;
|
|
$host = idn_to_ascii($host, 0, $variant);
|
|
}
|
|
if (!is_string($host) || !preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host)) {
|
|
throw new \InvalidArgumentException('Некорректный хост удалённого URL');
|
|
}
|
|
|
|
return $host;
|
|
}
|
|
|
|
protected static function redirectUrl($base, $location)
|
|
{
|
|
$location = trim((string) $location);
|
|
if ($location === '' || preg_match('/[\x00-\x20\x7f]/', $location)) {
|
|
throw new \RuntimeException('Удалённый сервер вернул некорректное перенаправление');
|
|
}
|
|
if (preg_match('#^https?://#i', $location)) {
|
|
return $location;
|
|
}
|
|
|
|
$parts = parse_url($base);
|
|
if (strpos($location, '//') === 0) {
|
|
return $parts['scheme'] . ':' . $location;
|
|
}
|
|
$origin = $parts['scheme'] . '://' . (strpos($parts['host'], ':') !== false ? '[' . $parts['host'] . ']' : $parts['host']);
|
|
if (isset($parts['port'])) { $origin .= ':' . (int) $parts['port']; }
|
|
if ($location[0] === '/') {
|
|
return $origin . $location;
|
|
}
|
|
if ($location[0] === '?') {
|
|
return $origin . (isset($parts['path']) ? $parts['path'] : '/') . $location;
|
|
}
|
|
|
|
$directory = isset($parts['path']) ? dirname($parts['path']) : '/';
|
|
$segments = explode('/', trim($directory . '/' . $location, '/'));
|
|
$normalized = array();
|
|
foreach ($segments as $segment) {
|
|
if ($segment === '' || $segment === '.') { continue; }
|
|
if ($segment === '..') { array_pop($normalized); continue; }
|
|
$normalized[] = $segment;
|
|
}
|
|
|
|
return $origin . '/' . implode('/', $normalized);
|
|
}
|
|
|
|
protected static function options(array $options)
|
|
{
|
|
$options = array_merge(array(
|
|
'allowed_schemes' => array('https'),
|
|
'allowed_ports' => array(443),
|
|
'connect_timeout' => 5,
|
|
'timeout' => 20,
|
|
'max_redirects' => 0,
|
|
'max_bytes' => self::DEFAULT_MAX_BYTES,
|
|
'headers' => array(),
|
|
'user_agent' => 'AVE.cms/' . (defined('APP_VERSION') ? APP_VERSION : '3.3') . ' OutboundHttpClient',
|
|
), $options);
|
|
$options['allowed_schemes'] = array_values(array_intersect(array('http', 'https'), array_map('strtolower', (array) $options['allowed_schemes'])));
|
|
$options['allowed_ports'] = array_values(array_unique(array_map('intval', (array) $options['allowed_ports'])));
|
|
$options['connect_timeout'] = max(1, min(30, (int) $options['connect_timeout']));
|
|
$options['timeout'] = max($options['connect_timeout'], min(120, (int) $options['timeout']));
|
|
$options['max_redirects'] = max(0, min(5, (int) $options['max_redirects']));
|
|
$options['max_bytes'] = max(1, min(52428800, (int) $options['max_bytes']));
|
|
$options['headers'] = self::headers((array) $options['headers']);
|
|
if (!$options['allowed_schemes'] || !$options['allowed_ports']) {
|
|
throw new \InvalidArgumentException('Не заданы разрешённые схемы или порты исходящего запроса');
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
protected static function headers(array $headers)
|
|
{
|
|
$result = array();
|
|
foreach ($headers as $header) {
|
|
$header = trim((string) $header);
|
|
if ($header !== '' && strpos($header, ':') !== false && !preg_match('/[\r\n]/', $header)) {
|
|
$result[] = $header;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected static function withoutSensitiveHeaders(array $headers)
|
|
{
|
|
return array_values(array_filter($headers, function ($header) {
|
|
return !preg_match('/^(?:authorization|cookie|proxy-authorization)\s*:/i', (string) $header);
|
|
}));
|
|
}
|
|
|
|
protected static function origin(array $target)
|
|
{
|
|
return $target['scheme'] . '://' . $target['host'] . ':' . $target['port'];
|
|
}
|
|
|
|
protected static function sameIp($first, $second)
|
|
{
|
|
$first = @inet_pton((string) $first);
|
|
$second = @inet_pton((string) $second);
|
|
return $first !== false && $second !== false && hash_equals($first, $second);
|
|
}
|
|
}
|