From dca03cc3458e487362c26c746378a2d4de9990b1 Mon Sep 17 00:00:00 2001 From: Yurii Date: Sun, 22 Mar 2026 23:31:11 +0200 Subject: [PATCH] image url curl resolve --- .../Espo/Core/Utils/Security/HostCheck.php | 56 +++++++++-- .../Espo/Core/Utils/Security/UrlCheck.php | 93 ++++++++++++++++++- .../Tools/Attachment/UploadUrlService.php | 15 +++ 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/application/Espo/Core/Utils/Security/HostCheck.php b/application/Espo/Core/Utils/Security/HostCheck.php index bcd2f8c6bb..8e637dc52a 100644 --- a/application/Espo/Core/Utils/Security/HostCheck.php +++ b/application/Espo/Core/Utils/Security/HostCheck.php @@ -50,6 +50,31 @@ class HostCheck return $this->ipAddressIsNotInternal($host); } + if (!$this->isDomainHost($host)) { + return false; + } + + $ipAddresses = $this->getHostIpAddresses($host); + + if ($ipAddresses === []) { + return false; + } + + foreach ($ipAddresses as $idAddress) { + if (!$this->ipAddressIsNotInternal($idAddress)) { + return false; + } + } + + return true; + } + + /** + * @internal + * @since 9.3.4 + */ + public function isDomainHost(string $host): bool + { $normalized = $this->normalizeIpAddress($host); if ($normalized !== false && filter_var($normalized, FILTER_VALIDATE_IP)) { @@ -64,29 +89,46 @@ class HostCheck return false; } + if (filter_var($host, FILTER_VALIDATE_DOMAIN)) { + return true; + } + + return false; + } + + /** + * @return string[] + * @internal + * @since 9.3.4 + */ + public function getHostIpAddresses(string $host): array + { $records = dns_get_record($host, DNS_A); if (!$records) { - return true; + return []; } + $output = []; + foreach ($records as $record) { /** @var ?string $idAddress */ $idAddress = $record['ip'] ?? null; if (!$idAddress) { - return false; + continue; } - if (!$this->ipAddressIsNotInternal($idAddress)) { - return false; - } + $output[] = $idAddress; } - return true; + return $output; } - private function ipAddressIsNotInternal(string $ipAddress): bool + /** + * @internal + */ + public function ipAddressIsNotInternal(string $ipAddress): bool { return (bool) filter_var( $ipAddress, diff --git a/application/Espo/Core/Utils/Security/UrlCheck.php b/application/Espo/Core/Utils/Security/UrlCheck.php index 6d8a57b009..2a12ea00fc 100644 --- a/application/Espo/Core/Utils/Security/UrlCheck.php +++ b/application/Espo/Core/Utils/Security/UrlCheck.php @@ -29,9 +29,6 @@ namespace Espo\Core\Utils\Security; -use const FILTER_VALIDATE_URL; -use const PHP_URL_HOST; - class UrlCheck { public function __construct( @@ -63,6 +60,65 @@ class UrlCheck return $this->hostCheck->isHostAndNotInternal($host); } + /** + * @return ?string[] Null if not a domain name or not a URL. + * @internal + * @since 9.3.4 + */ + public function getCurlResolve(string $url): ?array + { + if (!$this->isUrl($url)) { + return null; + } + + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + $scheme = parse_url($url, PHP_URL_SCHEME); + + if ($port === null && $scheme) { + $port = match (strtolower($scheme)) { + 'http' => 80, + 'https'=> 443, + 'ftp' => 21, + 'ssh' => 22, + 'smtp' => 25, + default => null, + }; + } + + if ($port === null) { + return []; + } + + if (!is_string($host)) { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP)) { + return null; + } + + if (!$this->hostCheck->isDomainHost($host)) { + return null; + } + + $ipAddresses = $this->hostCheck->getHostIpAddresses($host); + + $output = []; + + foreach ($ipAddresses as $ipAddress) { + $ipPart = $ipAddress; + + if (filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipPart = "[$ipPart]"; + } + + $output[] = "$host:$port:$ipPart"; + } + + return $output; + } + /** * @deprecated Since 9.3.4. Use `isUrlAndNotIternal`. * @todo Remove in 9.5.0. @@ -71,4 +127,35 @@ class UrlCheck { return $this->isUrlAndNotIternal($url); } + + /** + * @param string[] $resolve + * @internal + */ + public function validateCurlResolveNotInternal(array $resolve): bool + { + if ($resolve === []) { + return false; + } + + foreach ($resolve as $item) { + $arr = explode(':', $item, 3); + + if (count($arr) < 3) { + return false; + } + + $ipAddress = $arr[2]; + + if (str_starts_with($ipAddress, '[') && str_ends_with($ipAddress, ']')) { + $ipAddress = substr($ipAddress, 1, -1); + } + + if (!$this->hostCheck->ipAddressIsNotInternal($ipAddress)) { + return false; + } + } + + return true; + } } diff --git a/application/Espo/Tools/Attachment/UploadUrlService.php b/application/Espo/Tools/Attachment/UploadUrlService.php index e0d73b7f3a..26d43c06c1 100644 --- a/application/Espo/Tools/Attachment/UploadUrlService.php +++ b/application/Espo/Tools/Attachment/UploadUrlService.php @@ -114,9 +114,20 @@ class UploadUrlService /** * @param non-empty-string $url * @return ?array{string, string} A type and contents. + * @throws ForbiddenSilent */ private function getImageDataByUrl(string $url): ?array { + $resolve = $this->urlCheck->getCurlResolve($url); + + if ($resolve === []) { + throw new ForbiddenSilent("Could not resolve the host."); + } + + if ($resolve !== null && !$this->urlCheck->validateCurlResolveNotInternal($resolve)) { + throw new ForbiddenSilent("Forbidden host."); + } + $type = null; if (!function_exists('curl_init')) { @@ -144,6 +155,10 @@ class UploadUrlService $opts[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTPS | \CURLPROTO_HTTP; $opts[\CURLOPT_REDIR_PROTOCOLS] = \CURLPROTO_HTTPS; + if ($resolve) { + $opts[CURLOPT_RESOLVE] = $resolve; + } + $ch = curl_init(); curl_setopt_array($ch, $opts);