CVE-2023-46736 (published 2023-12-05T21:15Z)

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.

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
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.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/espocrm/espocrm
commit_details: repo_url: https://github.com/espocrm/espocrm
author: Yuri Kuznetsov
author_date: 2023-11-06 16:02:13+02:00
msg: image upload url check
repository: repo_url: https://github.com/espocrm/espocrm
repo_name: espocrm/espocrm
description: EspoCRM – Open Source CRM Application
repo_language: PHP
owner: espocrm
file_changes: filename: UrlCheck.php
change_type: ModificationType.ADD
diff: @@ -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
+        );
+    }
+}

programming_language: PHP
filename: UploadUrlService.php
change_type: ModificationType.MODIFY
diff: @@ -32,8 +32,10 @@ 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;
@@ -51,7 +53,8 @@ class UploadUrlService
         Metadata $metadata,
         EntityManager $entityManager,
         MimeType $mimeType,
-        DetailsObtainer $detailsObtainer
+        DetailsObtainer $detailsObtainer,
+        private UrlCheck $urlCheck
     ) {
         $this->accessChecker = $accessChecker;
         $this->metadata = $metadata;
@@ -68,6 +71,10 @@ class UploadUrlService
      */
     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);
@@ -130,9 +137,12 @@ class UploadUrlService
         $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();
 

programming_language: PHP