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
2Patches
10310dcdaf24fbinline attachment header change
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');
3fab34e030f1sanitize name
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);
88e3ba6a7b5cfix impot eml attachment
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 */
8a8f8453f833fix template manager
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"; }
50f07e2da1c0url check webhook
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);
dca03cc3458eimage url curl resolve
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);
27b1ce882b74fix markdown backend
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;
4c8690f5e397forbif hex ip
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') + ); } }
3fcb5bb2f414forbid only digits host names
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')
647f6f8ce817check alternative ip notatino
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
1News mentions
0No linked articles in our index yet.