CVE-2023-46736: Server-Side Request Forgery (SSRF) (CWE-918)
value: EspoCRM is an Open Source CRM (Customer Relationship Management) software. In affected versions there is Server-Side Request Forgery (SSRF) vulnerability via the upload image from url api. Users who have access to `the /Attachment/fromImageUrl` endpoint can specify URL to point to an internal host. Even though there is check for content type, it can be bypassed by redirects in some cases. This SSRF can be leveraged to disclose internal information (in some cases), target internal hosts and bypass firewalls. This vulnerability has been addressed in commit `c536cee63` which is included in release version 8.0.5. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 6.5
Published: 2023-12-05T21:15Z
Repository: https://github.com/espocrm/espocrm
Language: PHP
Files changed: application/Espo/Core/Utils/Security/UrlCheck.php, application/Espo/Tools/Attachment/UploadUrlService.php
Commit date: 2023-11-06 16:02:13 +0200
Commit message: image upload url check

Diff: diff --git a/application/Espo/Core/Utils/Security/UrlCheck.php b/application/Espo/Core/Utils/Security/UrlCheck.php
new file mode 100644
index 0000000000..bb1323548b
--- /dev/null
+++ b/application/Espo/Core/Utils/Security/UrlCheck.php
@@ -0,0 +1,95 @@
+<?php
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM - Open Source CRM application.
+ * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * EspoCRM is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * EspoCRM is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with EspoCRM. If not, see http://www.gnu.org/licenses/.
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+namespace Espo\Core\Utils\Security;
+
+use const DNS_A;
+use const FILTER_FLAG_NO_PRIV_RANGE;
+use const FILTER_FLAG_NO_RES_RANGE;
+use const FILTER_VALIDATE_IP;
+use const FILTER_VALIDATE_URL;
+use const PHP_URL_HOST;
+
+class UrlCheck
+{
+    public function isUrl(string $url): bool
+    {
+        return filter_var($url, FILTER_VALIDATE_URL) !== false;
+    }
+
+    /**
+     * Checks whether a URL does not follow to an internal host.
+     */
+    public function isNotInternalUrl(string $url): bool
+    {
+        if (!$this->isUrl($url)) {
+            return false;
+        }
+
+        $host = parse_url($url, PHP_URL_HOST);
+
+        if (!is_string($host)) {
+            return false;
+        }
+
+        $records = dns_get_record($host, DNS_A);
+
+        if (filter_var($host, FILTER_VALIDATE_IP)) {
+            return $this->ipAddressIsNotInternal($host);
+        }
+
+        if (!$records) {
+            return false;
+        }
+
+        foreach ($records as $record) {
+            /** @var ?string $idAddress */
+            $idAddress = $record['ip'] ?? null;
+
+            if (!$idAddress) {
+                return false;
+            }
+
+            if (!$this->ipAddressIsNotInternal($idAddress)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private function ipAddressIsNotInternal(string $ipAddress): bool
+    {
+        return (bool) filter_var(
+            $ipAddress,
+            FILTER_VALIDATE_IP,
+            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+        );
+    }
+}
diff --git a/application/Espo/Tools/Attachment/UploadUrlService.php b/application/Espo/Tools/Attachment/UploadUrlService.php
index 7dbeec065c..d1d8566b66 100644
--- a/application/Espo/Tools/Attachment/UploadUrlService.php
+++ b/application/Espo/Tools/Attachment/UploadUrlService.php
@@ -30,12 +30,14 @@
 namespace Espo\Tools\Attachment;
 
 use Espo\Core\Exceptions\Error;
 use Espo\Core\Exceptions\ErrorSilent;
 use Espo\Core\Exceptions\Forbidden;
+use Espo\Core\Exceptions\ForbiddenSilent;
 use Espo\Core\Utils\File\MimeType;
 use Espo\Core\Utils\Metadata;
+use Espo\Core\Utils\Security\UrlCheck;
 use Espo\Entities\Attachment as Attachment;
 use Espo\ORM\EntityManager;
 use Espo\Repositories\Attachment as AttachmentRepository;
 
 class UploadUrlService
@@ -49,11 +51,12 @@ class UploadUrlService
     public function __construct(
         AccessChecker $accessChecker,
         Metadata $metadata,
         EntityManager $entityManager,
         MimeType $mimeType,
-        DetailsObtainer $detailsObtainer
+        DetailsObtainer $detailsObtainer,
+        private UrlCheck $urlCheck
     ) {
         $this->accessChecker = $accessChecker;
         $this->metadata = $metadata;
         $this->entityManager = $entityManager;
         $this->mimeType = $mimeType;
@@ -66,10 +69,14 @@ class UploadUrlService
      * @throws Forbidden
      * @throws Error
      */
     public function uploadImage(string $url, FieldData $data): Attachment
     {
+        if (!$this->urlCheck->isNotInternalUrl($url)) {
+            throw new ForbiddenSilent("Not allowed URL.");
+        }
+
         $attachment = $this->getAttachmentRepository()->getNew();
 
         $this->accessChecker->check($data);
 
         [$type, $contents] = $this->getImageDataByUrl($url) ?? [null, null];
@@ -128,13 +135,16 @@ class UploadUrlService
         $opts[\CURLOPT_BINARYTRANSFER] = true;
         $opts[\CURLOPT_VERBOSE] = true;
         $opts[\CURLOPT_SSL_VERIFYPEER] = true;
         $opts[\CURLOPT_SSL_VERIFYHOST] = 2;
         $opts[\CURLOPT_RETURNTRANSFER] = true;
-        $opts[\CURLOPT_FOLLOWLOCATION] = true;
+        // Prevents Server Side Request Forgery by redirecting to an internal host.
+        $opts[\CURLOPT_FOLLOWLOCATION] = false;
         $opts[\CURLOPT_MAXREDIRS] = 2;
         $opts[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
+        $opts[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTPS | \CURLPROTO_HTTP;
+        $opts[\CURLOPT_REDIR_PROTOCOLS] = \CURLPROTO_HTTPS;
 
         $ch = curl_init();
 
         curl_setopt_array($ch, $opts);
 

Vulnerable code:         DetailsObtainer $detailsObtainer
        $opts[\CURLOPT_FOLLOWLOCATION] = true;
Fixed code: <?php
/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM - Open Source CRM application.
 * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
 * Website: https://www.espocrm.com
 *
 * EspoCRM is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * EspoCRM is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with EspoCRM. If not, see http://www.gnu.org/licenses/.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

namespace Espo\Core\Utils\Security;

use const DNS_A;
use const FILTER_FLAG_NO_PRIV_RANGE;
use const FILTER_FLAG_NO_RES_RANGE;
use const FILTER_VALIDATE_IP;
use const FILTER_VALIDATE_URL;
use const PHP_URL_HOST;

class UrlCheck
{
    public function isUrl(string $url): bool
    {
        return filter_var($url, FILTER_VALIDATE_URL) !== false;
    }

    /**
     * Checks whether a URL does not follow to an internal host.
     */
    public function isNotInternalUrl(string $url): bool
    {
        if (!$this->isUrl($url)) {
            return false;
        }

        $host = parse_url($url, PHP_URL_HOST);

        if (!is_string($host)) {
            return false;
        }

        $records = dns_get_record($host, DNS_A);

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return $this->ipAddressIsNotInternal($host);
        }

        if (!$records) {
            return false;
        }

        foreach ($records as $record) {
            /** @var ?string $idAddress */
            $idAddress = $record['ip'] ?? null;

            if (!$idAddress) {
                return false;
            }

            if (!$this->ipAddressIsNotInternal($idAddress)) {
                return false;
            }
        }

        return true;
    }

    private function ipAddressIsNotInternal(string $ipAddress): bool
    {
        return (bool) filter_var(
            $ipAddress,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }
}
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Utils\Security\UrlCheck;
        DetailsObtainer $detailsObtainer,
        private UrlCheck $urlCheck
        if (!$this->urlCheck->isNotInternalUrl($url)) {
            throw new ForbiddenSilent("Not allowed URL.");
        }

        // Prevents Server Side Request Forgery by redirecting to an internal host.
        $opts[\CURLOPT_FOLLOWLOCATION] = false;
        $opts[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTPS | \CURLPROTO_HTTP;
        $opts[\CURLOPT_REDIR_PROTOCOLS] = \CURLPROTO_HTTPS;