VYPR
Medium severity6.8NVD Advisory· Published May 19, 2026· Updated May 20, 2026

CVE-2026-33741

CVE-2026-33741

Description

EspoCRM is an open source customer relationship management application. Versions 9.3.3 and below allow authenticated users to upload SVG attachments through normal attachment-capable fields and later serve those SVG files as top-level inline documents through both the attachment and image entry points, resulting in stored cross-user XSS reachable through a normal attachment workflow. Although inline SVG script is blocked by the response CSP, the same CSP still allows same-origin external script. As a result, an attacker can upload a malicious SVG together with a second attacker-controlled JavaScript attachment, then trick another user into opening the SVG to execute JavaScript in the victim's EspoCRM origin. This issue has been fixed in version 9.3.4.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Stored cross-user XSS in EspoCRM via SVG attachments bypassing CSP by using same-origin external script; fixed in 9.3.4.

Vulnerability

EspoCRM versions 9.3.3 and below allow authenticated users to upload SVG attachments through normal attachment fields and serve them as top-level inline documents via the attachment and image entry points. While inline SVG scripts are blocked by the Content-Security-Policy (CSP) header default-src 'self', the CSP permits same-origin external scripts. This allows an attacker to upload a malicious SVG alongside a second attacker-controlled JavaScript attachment, leading to stored cross-user XSS when another user opens the SVG [1].

Attack

Vector An authenticated attacker uploads an SVG file containing an ` tag with src` pointing to the second uploaded JavaScript attachment. The attacker then tricks another user into opening the SVG (e.g., by visiting the attachment or image URL). When the SVG is rendered as a top-level document, the browser loads and executes the JavaScript from the same origin, bypassing the CSP restriction that would block inline scripts [1].

Impact

The attacker can execute arbitrary JavaScript in the context of the victim's EspoCRM session, potentially accessing sensitive data, performing actions on behalf of the victim, or escalating privileges within the CRM [1].

Mitigation

The vulnerability has been patched in EspoCRM version 9.3.4. Users are advised to upgrade to this version or later [1].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Espocrm/Espocrminferred2 versions
    <=9.3.3+ 1 more
    • (no CPE)range: <=9.3.3
    • (no CPE)range: <=9.3.3

Patches

10
310dcdaf24fb

inline attachment header change

https://github.com/espocrm/espocrmYuriiMar 23, 2026Fixed in 9.3.4via llm-release-walk
3 files changed · +3 3
  • application/Espo/EntryPoints/Attachment.php+1 1 modified
    @@ -95,7 +95,7 @@ public function run(Request $request, Response $response): void
             $response
                 ->setHeader('Content-Length', (string) $size)
                 ->setHeader('Cache-Control', 'private, max-age=864000, immutable')
    -            ->setHeader('Content-Security-Policy', "default-src 'self'")
    +            ->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';")
                 ->setBody($stream);
         }
     
    
  • application/Espo/EntryPoints/Download.php+1 1 modified
    @@ -87,7 +87,7 @@ public function run(Request $request, Response $response): void
             if (in_array($type, $inlineMimeTypeList)) {
                 $disposition = 'inline';
     
    -            $response->setHeader('Content-Security-Policy', "default-src 'self'");
    +            $response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
             }
     
             $response->setHeader('Content-Description', 'File Transfer');
    
  • application/Espo/EntryPoints/Image.php+1 1 modified
    @@ -153,7 +153,7 @@ protected function show(
             $response
                 ->setHeader('Content-Disposition', 'inline;filename="' . $fileName . '"')
                 ->setHeader('Content-Length', (string) $fileSize)
    -            ->setHeader('Content-Security-Policy', "default-src 'self'");
    +            ->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
     
             if (!$noCacheHeaders) {
                 $response->setHeader('Cache-Control', 'private, max-age=864000, immutable');
    
3fab34e030f1

sanitize name

https://github.com/espocrm/espocrmYuriiMar 21, 2026Fixed in 9.3.4via llm-release-walk
6 files changed · +16 7
  • application/Espo/Classes/MassAction/User/MassUpdate.php+3 1 modified
    @@ -178,7 +178,9 @@ private function afterProcess(Result $result, MassUpdateData $dataWrapped): void
     
         private function clearRoleCache(string $id): void
         {
    -        $this->fileManager->removeFile('data/cache/application/acl/' . $id . '.php');
    +        $part = basename($id);
    +
    +        $this->fileManager->removeFile("data/cache/application/acl/$part.php");
         }
     
         private function clearPortalRolesCache(): void
    
  • application/Espo/Core/Acl/Cache/Clearer.php+2 2 modified
    @@ -63,7 +63,7 @@ public function clearForUser(User $user): void
                 return;
             }
     
    -        $part = $user->getId() . '.php';
    +        $part = basename($user->getId() . '.php');
     
             $this->fileManager->remove('data/cache/application/acl/' . $part);
             $this->fileManager->remove('data/cache/application/aclMap/' . $part);
    @@ -77,7 +77,7 @@ private function clearForPortalUser(User $user): void
                 ->find();
     
             foreach ($portals as $portal) {
    -            $part = $portal->getId() . '/' . $user->getId() . '.php';
    +            $part = basename($portal->getId()) . '/' . basename($user->getId() . '.php');
     
                 $this->fileManager->remove('data/cache/application/aclPortal/' . $part);
                 $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part);
    
  • application/Espo/Core/FileStorage/Storages/EspoUploadDir.php+2 1 modified
    @@ -115,7 +115,8 @@ public function getLocalFilePath(Attachment $attachment): string
         protected function getFilePath(Attachment $attachment)
         {
             $sourceId = $attachment->getSourceId();
    +        $file = basename($sourceId);
     
    -        return 'data/upload/' . $sourceId;
    +        return 'data/upload/' . $file;
         }
     }
    
  • application/Espo/Core/Portal/Api/Starter.php+3 1 modified
    @@ -49,7 +49,9 @@ public function __construct(
             SystemConfig $systemConfig,
             ApplicationState $applicationState
         ) {
    -        $routeCacheFile = 'data/cache/application/slim-routes-portal-' . $applicationState->getPortalId() . '.php';
    +        $part = basename($applicationState->getPortalId());
    +
    +        $routeCacheFile = 'data/cache/application/slim-routes-portal-' . $part . '.php';
     
             parent::__construct(
                 $requestProcessor,
    
  • application/Espo/EntryPoints/Image.php+3 1 modified
    @@ -174,7 +174,9 @@ private function getThumbContents(Attachment $attachment, string $size): ?string
     
             $sourceId = $attachment->getSourceId();
     
    -        $cacheFilePath = "data/upload/thumbs/{$sourceId}_$size";
    +        $file = basename("{$sourceId}_$size");
    +
    +        $cacheFilePath = "data/upload/thumbs/$file";
     
             if ($useCache && $this->fileManager->isFile($cacheFilePath)) {
                 return $this->fileManager->getContents($cacheFilePath);
    
  • application/Espo/Hooks/Attachment/RemoveFile.php+3 1 modified
    @@ -89,7 +89,9 @@ private function removeThumbs(Attachment $entity): void
             $sizeList = array_keys($this->metadata->get(['app', 'image', 'sizes']) ?? []);
     
             foreach ($sizeList as $size) {
    -            $filePath = "data/upload/thumbs/{$entity->getSourceId()}_{$size}";
    +            $file = basename("{$entity->getSourceId()}_$size");
    +
    +            $filePath = "data/upload/thumbs/$file";
     
                 if ($this->fileManager->isFile($filePath)) {
                     $this->fileManager->removeFile($filePath);
    
88e3ba6a7b5c

fix impot eml attachment

https://github.com/espocrm/espocrmYuriiMar 23, 2026Fixed in 9.3.4via llm-release-walk
2 files changed · +27 20
  • application/Espo/Tools/Email/Api/PostImportEml.php+26 1 modified
    @@ -36,8 +36,11 @@
     use Espo\Core\Api\ResponseComposer;
     use Espo\Core\Exceptions\BadRequest;
     use Espo\Core\Exceptions\Forbidden;
    +use Espo\Core\Exceptions\NotFound;
    +use Espo\Entities\Attachment;
     use Espo\Entities\Email;
     use Espo\Entities\User;
    +use Espo\ORM\EntityManager;
     use Espo\Tools\Email\ImportEmlService;
     
     /**
    @@ -49,6 +52,7 @@ public function __construct(
             private Acl $acl,
             private User $user,
             private ImportEmlService $service,
    +        private EntityManager $entityManager,
         ) {}
     
         public function process(Request $request): Response
    @@ -61,11 +65,32 @@ public function process(Request $request): Response
                 throw new BadRequest("No 'fileId'.");
             }
     
    -        $email = $this->service->import($fileId, $this->user->getId());
    +        $attachment = $this->getAttachment($fileId);
    +
    +        $email = $this->service->import($attachment, $this->user->getId());
     
             return ResponseComposer::json(['id' => $email->getId()]);
         }
     
    +    /**
    +     * @throws NotFound
    +     * @throws Forbidden
    +     */
    +    private function getAttachment(string $fileId): Attachment
    +    {
    +        $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($fileId);
    +
    +        if (!$attachment) {
    +            throw new NotFound("Attachment not found.");
    +        }
    +
    +        if (!$this->acl->checkEntityRead($attachment)) {
    +            throw new Forbidden("No access to attachment.");
    +        }
    +
    +        return $attachment;
    +    }
    +
         /**
          * @throws Forbidden
          */
    
  • application/Espo/Tools/Email/ImportEmlService.php+1 19 modified
    @@ -31,7 +31,6 @@
     
     use Espo\Core\Exceptions\Conflict;
     use Espo\Core\Exceptions\Error;
    -use Espo\Core\Exceptions\NotFound;
     use Espo\Core\FileStorage\Manager;
     use Espo\Core\Mail\Exceptions\ImapError;
     use Espo\Core\Mail\Importer;
    @@ -56,16 +55,13 @@ public function __construct(
         /**
          * Import an EML.
          *
    -     * @param string $fileId An attachment ID.
          * @param ?string $userId A user ID to relate an email with.
          * @return Email An Email.
    -     * @throws NotFound
          * @throws Error
          * @throws Conflict
          */
    -    public function import(string $fileId, ?string $userId = null): Email
    +    public function import(Attachment $attachment, ?string $userId = null): Email
         {
    -        $attachment = $this->getAttachment($fileId);
             $contents = $this->fileStorageManager->getContents($attachment);
     
             try {
    @@ -93,20 +89,6 @@ public function import(string $fileId, ?string $userId = null): Email
             return $email;
         }
     
    -    /**
    -     * @throws NotFound
    -     */
    -    private function getAttachment(string $fileId): Attachment
    -    {
    -        $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($fileId);
    -
    -        if (!$attachment) {
    -            throw new NotFound("Attachment not found.");
    -        }
    -
    -        return $attachment;
    -    }
    -
         /**
          * @throws Conflict
          */
    
8a8f8453f833

fix template manager

https://github.com/espocrm/espocrmYuriiMar 23, 2026Fixed in 9.3.4via llm-release-walk
1 file changed · +12 0
  • application/Espo/Core/Utils/TemplateFileManager.php+12 0 modified
    @@ -124,7 +124,13 @@ private function getCustomFilePath(
             ?string $entityType = null
         ): string {
     
    +        $type = basename($type);
    +        $language = basename($language);
    +        $name = basename($name);
    +
             if ($entityType) {
    +            $entityType = basename($entityType);
    +
                 return "custom/Espo/Custom/Resources/templates/{$type}/{$language}/{$entityType}/{$name}.tpl";
             }
     
    @@ -152,7 +158,13 @@ private function getPathForLanguage(
             ?string $entityType = null
         ): string {
     
    +        $type = basename($type);
    +        $language = basename($language);
    +        $name = basename($name);
    +
             if ($entityType) {
    +            $entityType = basename($entityType);
    +
                 return "templates/{$type}/{$language}/{$entityType}/{$name}.tpl";
             }
     
    
50f07e2da1c0

url check webhook

https://github.com/espocrm/espocrmYuriiMar 23, 2026Fixed in 9.3.4via llm-release-walk
2 files changed · +31 1
  • application/Espo/Core/Utils/Security/UrlCheck.php+14 1 modified
    @@ -130,14 +130,17 @@ public function isNotInternalUrl(string $url): bool
     
         /**
          * @param string[] $resolve
    +     * @param string[] $allowed An allowed address list in the `{host}:{port}` format.
          * @internal
          */
    -    public function validateCurlResolveNotInternal(array $resolve): bool
    +    public function validateCurlResolveNotInternal(array $resolve, array $allowed = []): bool
         {
             if ($resolve === []) {
                 return false;
             }
     
    +        $ipAddresses = [];
    +
             foreach ($resolve as $item) {
                 $arr = explode(':', $item, 3);
     
    @@ -146,11 +149,21 @@ public function validateCurlResolveNotInternal(array $resolve): bool
                 }
     
                 $ipAddress = $arr[2];
    +            $port = $arr[1];
    +            $domain = $arr[0];
    +
    +            if (in_array("$ipAddress:$port", $allowed) || in_array("$domain:$port", $allowed)) {
    +                return true;
    +            }
     
                 if (str_starts_with($ipAddress, '[') && str_ends_with($ipAddress, ']')) {
                     $ipAddress = substr($ipAddress, 1, -1);
                 }
     
    +            $ipAddresses[] = $ipAddress;
    +        }
    +
    +        foreach ($ipAddresses as $ipAddress) {
                 if (!$this->hostCheck->ipAddressIsNotInternal($ipAddress)) {
                     return false;
                 }
    
  • application/Espo/Core/Webhook/Sender.php+17 0 modified
    @@ -100,6 +100,19 @@ public function send(Webhook $webhook, array $dataList): int
                 throw new Error("URL '$url' points to an internal host, not allowed.");
             }
     
    +        $resolve = $this->urlCheck->getCurlResolve($url);
    +
    +        if ($resolve === []) {
    +            throw new Error("Could not resolve the host.");
    +        }
    +
    +        /** @var string[] $allowedAddressList */
    +        $allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? [];
    +
    +        if ($resolve !== null && !$this->urlCheck->validateCurlResolveNotInternal($resolve, $allowedAddressList)) {
    +            throw new Error("Forbidden host.");
    +        }
    +
             $handler = curl_init($url);
     
             if ($handler === false) {
    @@ -118,6 +131,10 @@ public function send(Webhook $webhook, array $dataList): int
             curl_setopt($handler, \CURLOPT_HTTPHEADER, $headerList);
             curl_setopt($handler, \CURLOPT_POSTFIELDS, $payload);
     
    +        if ($resolve) {
    +            curl_setopt($handler, CURLOPT_RESOLVE, $resolve);
    +        }
    +
             curl_exec($handler);
     
             $code = curl_getinfo($handler, \CURLINFO_HTTP_CODE);
    
dca03cc3458e

image url curl resolve

https://github.com/espocrm/espocrmYuriiMar 22, 2026Fixed in 9.3.4via llm-release-walk
3 files changed · +154 10
  • application/Espo/Core/Utils/Security/HostCheck.php+49 7 modified
    @@ -50,6 +50,31 @@ public function isHostAndNotInternal(string $host): bool
                 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 @@ public function isHostAndNotInternal(string $host): bool
                 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,
    
  • application/Espo/Core/Utils/Security/UrlCheck.php+90 3 modified
    @@ -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 @@ public function isUrlAndNotIternal(string $url): bool
             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 @@ public function isNotInternalUrl(string $url): bool
         {
             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;
    +    }
     }
    
  • application/Espo/Tools/Attachment/UploadUrlService.php+15 0 modified
    @@ -114,9 +114,20 @@ public function uploadImage(string $url, FieldData $data): Attachment
         /**
          * @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 @@ private function getImageDataByUrl(string $url): ?array
             $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);
    
27b1ce882b74

fix markdown backend

https://github.com/espocrm/espocrmYuriiMar 21, 2026Fixed in 9.3.4via llm-release-walk
4 files changed · +59 13
  • application/Espo/Classes/TemplateHelpers/MarkdownText.php+2 2 modified
    @@ -32,7 +32,7 @@
     use Espo\Core\Htmlizer\Helper;
     use Espo\Core\Htmlizer\Helper\Data;
     use Espo\Core\Htmlizer\Helper\Result;
    -use Michelf\MarkdownExtra as MarkdownTransformer;
    +use Espo\Core\Utils\Markdown\Markdown;
     
     class MarkdownText implements Helper
     {
    @@ -44,7 +44,7 @@ public function render(Data $data): Result
                 return Result::createEmpty();
             }
     
    -        $transformed = MarkdownTransformer::defaultTransform($value);
    +        $transformed = Markdown::transform($value);
     
             return Result::createSafeString($transformed);
         }
    
  • application/Espo/Core/Formula/Functions/ExtGroup/MarkdownGroup/TransformType.php+2 2 modified
    @@ -33,7 +33,7 @@
     use Espo\Core\Formula\Exceptions\BadArgumentType;
     use Espo\Core\Formula\Exceptions\TooFewArguments;
     use Espo\Core\Formula\Func;
    -use Michelf\Markdown;
    +use Espo\Core\Utils\Markdown\Markdown;
     
     /**
      * @noinspection PhpUnused
    @@ -52,6 +52,6 @@ public function process(EvaluatedArgumentList $arguments): string
                 throw BadArgumentType::create(1, 'string');
             }
     
    -        return Markdown::defaultTransform($string);
    +        return Markdown::transform($string);
         }
     }
    
  • application/Espo/Core/Utils/Markdown/Markdown.php+50 0 added
    @@ -0,0 +1,50 @@
    +<?php
    +/************************************************************************
    + * This file is part of EspoCRM.
    + *
    + * EspoCRM – Open Source CRM application.
    + * Copyright (C) 2014-2026 EspoCRM, Inc.
    + * Website: https://www.espocrm.com
    + *
    + * This program is free software: you can redistribute it and/or modify
    + * it under the terms of the GNU Affero General Public License as published by
    + * the Free Software Foundation, either version 3 of the License, or
    + * (at your option) any later version.
    + *
    + * This program 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 Affero General Public License for more details.
    + *
    + * You should have received a copy of the GNU Affero General Public License
    + * along with this program. If not, see <https://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 Affero General Public License version 3.
    + *
    + * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
    + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
    + ************************************************************************/
    +
    +namespace Espo\Core\Utils\Markdown;
    +
    +use Michelf\Markdown as MarkdownParser;
    +
    +/**
    + * @internal
    + */
    +class Markdown
    +{
    +    /**
    +     * @internal
    +     */
    +    public static function transform(string $text): string
    +    {
    +        $parser = new MarkdownParser();
    +        $parser->no_markup = true;
    +        $parser->no_entities = true;
    +
    +        return $parser->transform($text);
    +    }
    +}
    
  • application/Espo/Tools/EmailNotification/Processor.php+5 9 modified
    @@ -36,6 +36,7 @@
     use Espo\Core\Mail\SenderParams;
     use Espo\Core\Utils\Config\ApplicationConfig;
     use Espo\Core\Utils\DateTime as DateTimeUtil;
    +use Espo\Core\Utils\Markdown\Markdown;
     use Espo\Entities\Note;
     use Espo\ORM\Collection;
     use Espo\Repositories\Portal as PortalRepository;
    @@ -58,8 +59,6 @@
     use Espo\Core\Utils\Util;
     use Espo\Tools\Stream\NoteAccessControl;
     
    -use Michelf\Markdown;
    -
     use Exception;
     use DateTime;
     use Throwable;
    @@ -325,11 +324,10 @@ protected function processNotificationMentionInPost(Notification $notification):
     
             $data['userName'] = $note->get('createdByName');
     
    -        $post = Markdown::defaultTransform(
    -            $note->get('post') ?? ''
    -        );
    +        $post = $note->getPost() ?? '';
     
    -        $data['post'] = $post;
    +
    +        $data['post'] = Markdown::transform($post);
     
             $subjectTpl = $this->templateFileManager->getTemplate('mention', 'subject');
             $bodyTpl = $this->templateFileManager->getTemplate('mention', 'body');
    @@ -486,9 +484,7 @@ protected function processNotificationNotePost(Note $note, User $user): void
     
             $data['userName'] = $note->get('createdByName');
     
    -        $post = Markdown::defaultTransform($note->getPost() ?? '');
    -
    -        $data['post'] = $post;
    +        $data['post'] = Markdown::transform($note->getPost() ?? '');
     
             $parent = null;
     
    
4c8690f5e397

forbif hex ip

https://github.com/espocrm/espocrmYuriiMar 21, 2026Fixed in 9.3.4via llm-release-walk
2 files changed · +10 1
  • application/Espo/Core/Utils/Security/HostCheck.php+6 1 modified
    @@ -166,7 +166,7 @@ private function hasNoNumericItem(string $host): bool
             $hasNoNumeric = false;
     
             foreach (explode('.', $host) as $it) {
    -            if (!is_numeric($it)) {
    +            if (!is_numeric($it) && !self::isHex($it)) {
                     $hasNoNumeric = true;
     
                     break;
    @@ -175,4 +175,9 @@ private function hasNoNumericItem(string $host): bool
     
             return $hasNoNumeric;
         }
    +
    +    private function isHex(string $value): bool
    +    {
    +        return preg_match('/^0x[0-9a-fA-F]+$/', $value) === 1;
    +    }
     }
    
  • tests/unit/Espo/Core/Utils/Security/HostCheckTest.php+4 0 modified
    @@ -65,5 +65,9 @@ public function testIsHostAndNotInternal(): void
             $this->assertFalse(
                 $hostCheck->isHostAndNotInternal('0x7f000001')
             );
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('0x7f.1')
    +        );
         }
     }
    
3fcb5bb2f414

forbid only digits host names

https://github.com/espocrm/espocrmYuriiMar 21, 2026Fixed in 9.3.4via llm-release-walk
2 files changed · +26 2
  • application/Espo/Core/Utils/Security/HostCheck.php+20 0 modified
    @@ -60,6 +60,10 @@ public function isHostAndNotInternal(string $host): bool
                 return false;
             }
     
    +        if (!$this->hasNoNumericItem($host)) {
    +            return false;
    +        }
    +
             $records = dns_get_record($host, DNS_A);
     
             if (!$records) {
    @@ -155,4 +159,20 @@ private static function normalizePart(string $ip): string|false
     
             return long2ip($num);
         }
    +
    +
    +    private function hasNoNumericItem(string $host): bool
    +    {
    +        $hasNoNumeric = false;
    +
    +        foreach (explode('.', $host) as $it) {
    +            if (!is_numeric($it)) {
    +                $hasNoNumeric = true;
    +
    +                break;
    +            }
    +        }
    +
    +        return $hasNoNumeric;
    +    }
     }
    
  • tests/unit/Espo/Core/Utils/Security/HostCheckTest.php+6 2 modified
    @@ -50,13 +50,17 @@ public function testIsHostAndNotInternal(): void
                 $hostCheck->isHostAndNotInternal('0177.0.0.1')
             );
     
    -        /*$this->assertFalse(
    +        $this->assertFalse(
                 $hostCheck->isHostAndNotInternal('127.1')
             );
     
             $this->assertFalse(
                 $hostCheck->isHostAndNotInternal('127.0.1')
    -        );*/
    +        );
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('2130706433')
    +        );
     
             $this->assertFalse(
                 $hostCheck->isHostAndNotInternal('0x7f000001')
    
647f6f8ce817

check alternative ip notatino

https://github.com/espocrm/espocrmYuriiMar 21, 2026Fixed in 9.3.4via llm-release-walk
2 files changed · +127 0
  • application/Espo/Core/Utils/Security/HostCheck.php+62 0 modified
    @@ -50,6 +50,12 @@ public function isHostAndNotInternal(string $host): bool
                 return $this->ipAddressIsNotInternal($host);
             }
     
    +        $normalized = $this->normalizeIpAddress($host);
    +
    +        if ($normalized !== false && filter_var($normalized, FILTER_VALIDATE_IP)) {
    +            return false;
    +        }
    +
             if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
                 return false;
             }
    @@ -93,4 +99,60 @@ public function isNotInternalHost(string $host): bool
         {
             return $this->isHostAndNotInternal($host);
         }
    +
    +    private function normalizeIpAddress(string $ip): string|false
    +    {
    +        if (!str_contains($ip, '.')) {
    +            return self::normalizePart($ip);
    +        }
    +
    +        $parts = explode('.', $ip);
    +
    +        if (count($parts) !== 4) {
    +            return false;
    +        }
    +
    +        $result = [];
    +
    +        foreach ($parts as $part) {
    +            if (preg_match('/^0x[0-9a-f]+$/i', $part)) {
    +                $num = hexdec($part);
    +            } else if (preg_match('/^0[0-7]+$/', $part) && $part !== '0') {
    +                $num = octdec($part);
    +            } else if (ctype_digit($part)) {
    +                $num = (int)$part;
    +            } else {
    +                return false;
    +            }
    +
    +            if ($num < 0 || $num > 255) {
    +                return false;
    +            }
    +
    +            $result[] = $num;
    +        }
    +
    +        return implode('.', $result);
    +    }
    +
    +    private static function normalizePart(string $ip): string|false
    +    {
    +        if (preg_match('/^0x[0-9a-f]+$/i', $ip)) {
    +            $num = hexdec($ip);
    +        } elseif (preg_match('/^0[0-7]+$/', $ip) && $ip !== '0') {
    +            $num = octdec($ip);
    +        } elseif (ctype_digit($ip)) {
    +            $num = (int) $ip;
    +        } else {
    +            return false;
    +        }
    +
    +        if ($num < 0 || $num > 0xFFFFFFFF) {
    +            return false;
    +        }
    +
    +        $num = (int) $num;
    +
    +        return long2ip($num);
    +    }
     }
    
  • tests/unit/Espo/Core/Utils/Security/HostCheckTest.php+65 0 added
    @@ -0,0 +1,65 @@
    +<?php
    +/************************************************************************
    + * This file is part of EspoCRM.
    + *
    + * EspoCRM – Open Source CRM application.
    + * Copyright (C) 2014-2026 EspoCRM, Inc.
    + * Website: https://www.espocrm.com
    + *
    + * This program is free software: you can redistribute it and/or modify
    + * it under the terms of the GNU Affero General Public License as published by
    + * the Free Software Foundation, either version 3 of the License, or
    + * (at your option) any later version.
    + *
    + * This program 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 Affero General Public License for more details.
    + *
    + * You should have received a copy of the GNU Affero General Public License
    + * along with this program. If not, see <https://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 Affero General Public License version 3.
    + *
    + * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
    + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
    + ************************************************************************/
    +
    +namespace tests\unit\Espo\Core\Utils\Security;
    +
    +use Espo\Core\Utils\Security\HostCheck;
    +use PHPUnit\Framework\TestCase;
    +
    +class HostCheckTest extends TestCase
    +{
    +    public function testIsHostAndNotInternal(): void
    +    {
    +        $hostCheck = new HostCheck();
    +
    +        $this->assertTrue(
    +            $hostCheck->isHostAndNotInternal('200.1.1.1')
    +        );
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('172.20.0.1')
    +        );
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('0177.0.0.1')
    +        );
    +
    +        /*$this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('127.1')
    +        );
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('127.0.1')
    +        );*/
    +
    +        $this->assertFalse(
    +            $hostCheck->isHostAndNotInternal('0x7f000001')
    +        );
    +    }
    +}
    

Vulnerability mechanics

Root cause

"Missing script-src and object-src CSP directives in attachment-serving entry points allow same-origin script execution from attacker-uploaded SVG files."

Attack vector

An authenticated attacker uploads a malicious SVG file containing a reference to a second attacker-controlled JavaScript attachment, both stored on the same EspoCRM origin. The attacker then tricks another authenticated user into opening the SVG via the attachment or image entry point. Because the previous CSP only set `default-src 'self'` without restricting `script-src`, the browser permits the SVG to load and execute the attacker's JavaScript from the same origin [CWE-79]. The attack requires the victim to click a link to the SVG attachment, and the attacker must have upload privileges.

Affected code

The vulnerability exists in three entry points that serve user-uploaded files inline: `application/Espo/EntryPoints/Attachment.php`, `application/Espo/EntryPoints/Download.php`, and `application/Espo/EntryPoints/Image.php`. All three set a Content-Security-Policy header with only `default-src 'self'`, which does not restrict script execution from the same origin. The patch modifies the CSP string in each file to add `script-src 'none'; object-src 'none';`.

What the fix does

The patch adds `script-src 'none'; object-src 'none';` to the Content-Security-Policy header in three entry points: Attachment.php, Download.php, and Image.php [patch_id=810105]. This explicitly blocks execution of any script or plugin content even when loaded from the same origin, closing the XSS vector. A second commit [patch_id=810106] adds `basename()` sanitization to template file path construction, preventing path traversal in the template manager, though this addresses a separate concern.

Preconditions

  • authAttacker must be an authenticated user with permission to upload attachments
  • inputAttacker must upload a malicious SVG file and a companion JavaScript file as attachments
  • networkVictim must be able to reach the EspoCRM instance over the network
  • authVictim must be an authenticated user who opens the SVG attachment link

Generated on May 20, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.