From a3c1002845af35a5e4d579d9c184a66c69cb1e0d Mon Sep 17 00:00:00 2001 From: Yurii Date: Wed, 4 Mar 2026 12:47:23 +0200 Subject: [PATCH 1/2] test fix --- tests/integration/Espo/Webhook/AclTest.php | 8 ++++---- tests/integration/Espo/Webhook/ProcessingTest.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/integration/Espo/Webhook/AclTest.php b/tests/integration/Espo/Webhook/AclTest.php index ee2bd59d39..678ba8d08e 100644 --- a/tests/integration/Espo/Webhook/AclTest.php +++ b/tests/integration/Espo/Webhook/AclTest.php @@ -88,7 +88,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase 'Content-Type' => 'application/json', 'X-Api-Key' => 'test-key', ], - '{"event":"Account.create", "url": "https://test"}' + '{"event":"Account.create", "url": "https://test.com"}' ); $this->auth(null, null, null, 'ApiKey', $request); @@ -126,7 +126,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase 'Content-Type' => 'application/json', 'X-Api-Key' => 'test-key', ], - '{"event":"Account.create", "url": "https://test"}' + '{"event":"Account.create", "url": "https://test.com"}' ); $this->auth(null, null, null, 'ApiKey', $request); @@ -164,7 +164,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase 'Content-Type' => 'application/json', 'X-Api-Key' => 'test-key', ], - '{"event":"Account.create", "url": "https://test"}' + '{"event":"Account.create", "url": "https://test.com"}' ); $this->auth(null, null, null, 'ApiKey', $request); @@ -204,7 +204,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase $webhook = $em->createEntity('Webhook', [ 'event' => 'Account.create', - 'url' => 'https://test', + 'url' => 'https://test.com', 'userId' => $user->getId(), ]); diff --git a/tests/integration/Espo/Webhook/ProcessingTest.php b/tests/integration/Espo/Webhook/ProcessingTest.php index 09fc5ddb5e..8d44816210 100644 --- a/tests/integration/Espo/Webhook/ProcessingTest.php +++ b/tests/integration/Espo/Webhook/ProcessingTest.php @@ -63,14 +63,14 @@ class ProcessingTest extends BaseTestCase $em->createEntity(Webhook::ENTITY_TYPE, [ 'event' => 'Account.create', 'userId' => $user->getId(), - 'url' => 'https://test', + 'url' => 'https://test.com', 'skipOwn' => true, ]); $em->createEntity(Webhook::ENTITY_TYPE, [ 'event' => 'Account.update', 'userId' => $user->getId(), - 'url' => 'https://test', + 'url' => 'https://test.com', 'skipOwn' => true, ]); @@ -203,7 +203,7 @@ class ProcessingTest extends BaseTestCase $em->createEntity(Webhook::ENTITY_TYPE, [ 'event' => 'Account.delete', 'userId' => $user->getId(), - 'url' => 'https://test', + 'url' => 'https://test.com', 'skipOwn' => true, ]); @@ -289,7 +289,7 @@ class ProcessingTest extends BaseTestCase $em->createEntity(Webhook::ENTITY_TYPE, [ 'event' => 'Account.fieldUpdate.name', 'userId' => $user->getId(), - 'url' => 'https://test', + 'url' => 'https://test.com', ]); $app = $this->createApplication(); From 841c66926f9e2b195d570b098cbe3fd3511a2854 Mon Sep 17 00:00:00 2001 From: Yurii Date: Wed, 4 Mar 2026 12:55:18 +0200 Subject: [PATCH 2/2] host check --- .../Common/Host/NotInternal.php | 62 +++++++++ .../Webhook/Url/NotInternal.php | 71 +++++++++++ .../EmailAccount/BeforeSaveValidateHosts.php | 120 ++++++++++++++++++ application/Espo/Controllers/InboundEmail.php | 3 + .../Mail/Account/GroupAccount/Service.php | 25 +++- .../Mail/Account/PersonalAccount/Service.php | 23 +++- .../Core/Mail/Account/Util/AddressUtil.php | 69 ++++++++++ .../Espo/Core/Utils/Security/HostCheck.php | 75 +++++++++++ .../Espo/Core/Utils/Security/UrlCheck.php | 42 +----- application/Espo/Core/Webhook/AddressUtil.php | 79 ++++++++++++ application/Espo/Core/Webhook/Sender.php | 19 ++- .../metadata/entityDefs/EmailAccount.json | 12 +- .../metadata/entityDefs/InboundEmail.json | 12 +- .../metadata/entityDefs/Webhook.json | 5 +- .../metadata/recordDefs/EmailAccount.json | 6 +- .../metadata/recordDefs/InboundEmail.json | 6 + .../Espo/Tools/Email/Api/PostSendTest.php | 13 +- 17 files changed, 589 insertions(+), 53 deletions(-) create mode 100644 application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php create mode 100644 application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php create mode 100644 application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php create mode 100644 application/Espo/Core/Mail/Account/Util/AddressUtil.php create mode 100644 application/Espo/Core/Utils/Security/HostCheck.php create mode 100644 application/Espo/Core/Webhook/AddressUtil.php diff --git a/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php b/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php new file mode 100644 index 0000000000..9e9a15c956 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php @@ -0,0 +1,62 @@ +. + * + * 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\Classes\FieldValidators\Common\Host; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Security\HostCheck; +use Espo\ORM\Entity; + +/** + * @implements Validator + * @since 9.3.2 + */ +class NotInternal implements Validator +{ + public function __construct( + private HostCheck $hostCheck, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return null; + } + + if (!$this->hostCheck->isNotInternalHost($value)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php b/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php new file mode 100644 index 0000000000..5dcdf31122 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php @@ -0,0 +1,71 @@ +. + * + * 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\Classes\FieldValidators\Webhook\Url; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Security\UrlCheck; +use Espo\Core\Webhook\AddressUtil; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class NotInternal implements Validator +{ + public function __construct( + private UrlCheck $urlCheck, + private AddressUtil $addressUtil, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return null; + } + + if (!$this->urlCheck->isUrl($value)) { + return null; + } + + if ($this->addressUtil->isAllowedUrl($value)) { + return null; + } + + if (!$this->urlCheck->isNotInternalUrl($value)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php new file mode 100644 index 0000000000..ed16cdf454 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php @@ -0,0 +1,120 @@ +. + * + * 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\Classes\RecordHooks\EmailAccount; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Security\HostCheck; +use Espo\Entities\EmailAccount; +use Espo\Entities\InboundEmail; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeSaveValidateHosts implements SaveHook +{ + public function __construct( + private Config $config, + private HostCheck $hostCheck, + ) {} + + public function process(Entity $entity): void + { + if ($entity->isAttributeChanged('host') || $entity->isAttributeChanged('port')) { + $this->validateImap($entity); + } + + if ($entity->isAttributeChanged('smtpHost') || $entity->isAttributeChanged('smtpPort')) { + $this->validateSmtp($entity); + } + } + + /** + * @throws Forbidden + */ + private function validateImap(EmailAccount|InboundEmail $entity): void + { + $host = $entity->getHost(); + $port = $entity->getPort(); + + if ($host === null || $port === null) { + return; + } + + $address = $host . ':' . $port; + + if (in_array($address, $this->getAllowedAddressList())) { + return; + } + + if (!$this->hostCheck->isNotInternalHost($host)) { + $message = $this->composeErrorMessage($host, $address); + + throw new Forbidden($message); + } + } + + /** + * @throws Forbidden + */ + private function validateSmtp(EmailAccount|InboundEmail $entity): void + { + $host = $entity->getSmtpHost(); + $port = $entity->getSmtpPort(); + + if ($host === null || $port === null) { + return; + } + + $address = $host . ':' . $port; + + if (!$this->hostCheck->isNotInternalHost($host)) { + $message = $this->composeErrorMessage($host, $address); + + throw new Forbidden($message); + } + } + + /** + * @return string[] + */ + private function getAllowedAddressList(): array + { + return $this->config->get('emailServerAllowedAddressList') ?? []; + } + + private function composeErrorMessage(string $host, string $address): string + { + return "Host '$host' is not allowed as it's internal. " . + "To allow, add `$address` to the config parameter `emailServerAllowedAddressList`."; + } +} diff --git a/application/Espo/Controllers/InboundEmail.php b/application/Espo/Controllers/InboundEmail.php index e9055641fc..e4481e8d5a 100644 --- a/application/Espo/Controllers/InboundEmail.php +++ b/application/Espo/Controllers/InboundEmail.php @@ -30,6 +30,7 @@ namespace Espo\Controllers; use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; use Espo\Core\Mail\Account\GroupAccount\Service; use Espo\Core\Mail\Account\Storage\Params as StorageParams; @@ -48,6 +49,7 @@ class InboundEmail extends Record * @return string[] * @throws Error * @throws ImapError + * @throws Forbidden */ public function postActionGetFolders(Request $request): array { @@ -67,6 +69,7 @@ class InboundEmail extends Record /** * @throws Error + * @throws Forbidden */ public function postActionTestConnection(Request $request): bool { diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Service.php b/application/Espo/Core/Mail/Account/GroupAccount/Service.php index da4edfc793..ec1ada0730 100644 --- a/application/Espo/Core/Mail/Account/GroupAccount/Service.php +++ b/application/Espo/Core/Mail/Account/GroupAccount/Service.php @@ -30,16 +30,19 @@ namespace Espo\Core\Mail\Account\GroupAccount; use Espo\Core\Exceptions\ErrorSilent; +use Espo\Core\Exceptions\Forbidden; use Espo\Core\Mail\Account\Account as Account; use Espo\Core\Exceptions\Error; use Espo\Core\Mail\Account\Fetcher; use Espo\Core\Mail\Account\Storage\Params; use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Mail\Account\Util\AddressUtil; use Espo\Core\Mail\Account\Util\NotificationHelper; use Espo\Core\Mail\Exceptions\ImapError; use Espo\Core\Mail\Exceptions\NoImap; use Espo\Core\Mail\Sender\Message; use Espo\Core\Utils\Log; +use Espo\Core\Utils\Security\HostCheck; use Exception; class Service @@ -49,7 +52,9 @@ class Service private AccountFactory $accountFactory, private StorageFactory $storageFactory, private Log $log, - private NotificationHelper $notificationHelper + private NotificationHelper $notificationHelper, + private HostCheck $hostCheck, + private AddressUtil $addressUtil, ) {} /** @@ -77,9 +82,18 @@ class Service * @return string[] * @throws Error * @throws ImapError + * @throws Forbidden */ public function getFolderList(Params $params): array { + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isNotInternalHost($params->getHost()) + ) { + throw new Forbidden("Not allowed internal host."); + } + if ($params->getId()) { $account = $this->accountFactory->create($params->getId()); @@ -95,6 +109,7 @@ class Service /** * @throws Error + * @throws Forbidden */ public function testConnection(Params $params): void { @@ -106,6 +121,14 @@ class Service ->withImapHandlerClassName($account->getImapHandlerClassName()); } + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isNotInternalHost($params->getHost()) + ) { + throw new Forbidden("Not allowed internal host."); + } + try { $storage = $this->storageFactory->createWithParams($params); $storage->getFolderNames(); diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Service.php b/application/Espo/Core/Mail/Account/PersonalAccount/Service.php index 2eb7010ee8..3e3712df29 100644 --- a/application/Espo/Core/Mail/Account/PersonalAccount/Service.php +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Service.php @@ -30,9 +30,11 @@ namespace Espo\Core\Mail\Account\PersonalAccount; use Espo\Core\Exceptions\ErrorSilent; +use Espo\Core\Mail\Account\Util\AddressUtil; use Espo\Core\Mail\Account\Util\NotificationHelper; use Espo\Core\Mail\Exceptions\ImapError; use Espo\Core\Mail\Exceptions\NoImap; +use Espo\Core\Utils\Config; use Espo\Core\Utils\Log; use Espo\Core\Mail\Account\Account as Account; use Espo\Core\Exceptions\Forbidden; @@ -40,6 +42,7 @@ use Espo\Core\Exceptions\Error; use Espo\Core\Mail\Account\Fetcher; use Espo\Core\Mail\Account\Storage\Params; use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Utils\Security\HostCheck; use Espo\Entities\User; use Espo\Core\Mail\Sender\Message; @@ -53,7 +56,9 @@ class Service private StorageFactory $storageFactory, private User $user, private Log $log, - private NotificationHelper $notificationHelper + private NotificationHelper $notificationHelper, + private HostCheck $hostCheck, + private AddressUtil $addressUtil, ) {} /** @@ -95,6 +100,14 @@ class Service throw new Forbidden(); } + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isNotInternalHost($params->getHost()) + ) { + throw new Forbidden("Not allowed internal host."); + } + if ($params->getId()) { $account = $this->accountFactory->create($params->getId()); @@ -128,6 +141,14 @@ class Service throw new Forbidden(); } + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isNotInternalHost($params->getHost()) + ) { + throw new Forbidden("Not allowed host."); + } + if ($params->getId()) { $account = $this->accountFactory->create($params->getId()); diff --git a/application/Espo/Core/Mail/Account/Util/AddressUtil.php b/application/Espo/Core/Mail/Account/Util/AddressUtil.php new file mode 100644 index 0000000000..a98d6e4959 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Util/AddressUtil.php @@ -0,0 +1,69 @@ +. + * + * 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\Mail\Account\Util; + +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\SmtpParams; +use Espo\Core\Utils\Config; + +/** + * @internal + */ +class AddressUtil +{ + public function __construct( + private Config $config, + ) {} + + /** + * @internal + */ + public function isAllowedAddress(Params|SmtpParams $params): bool + { + $host = $params instanceof Params ? $params->getHost() : $params->getServer(); + $port = $params->getPort(); + + if ($port === null || !$host) { + return false; + } + + $address = $host . ':' . $port; + + return in_array($address, $this->getAllowedAddressList()); + } + + /** + * @return string[] + */ + private function getAllowedAddressList(): array + { + return $this->config->get('emailServerAllowedAddressList') ?? []; + } +} diff --git a/application/Espo/Core/Utils/Security/HostCheck.php b/application/Espo/Core/Utils/Security/HostCheck.php new file mode 100644 index 0000000000..74e82644fb --- /dev/null +++ b/application/Espo/Core/Utils/Security/HostCheck.php @@ -0,0 +1,75 @@ +. + * + * 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\Security; + +use const DNS_A; +use const FILTER_FLAG_NO_PRIV_RANGE; +use const FILTER_FLAG_NO_RES_RANGE; +use const FILTER_VALIDATE_IP; + +class HostCheck +{ + public function isNotInternalHost(string $host): bool + { + $records = dns_get_record($host, DNS_A); + + if (filter_var($host, FILTER_VALIDATE_IP)) { + return $this->ipAddressIsNotInternal($host); + } + + if (!$records) { + return true; + } + + 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/Core/Utils/Security/UrlCheck.php b/application/Espo/Core/Utils/Security/UrlCheck.php index 1345b48a23..2fe60b70f4 100644 --- a/application/Espo/Core/Utils/Security/UrlCheck.php +++ b/application/Espo/Core/Utils/Security/UrlCheck.php @@ -29,15 +29,15 @@ 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 __construct( + private HostCheck $hostCheck, + ) {} + public function isUrl(string $url): bool { return filter_var($url, FILTER_VALIDATE_URL) !== false; @@ -58,38 +58,6 @@ class UrlCheck 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 - ); + return $this->hostCheck->isNotInternalHost($host); } } diff --git a/application/Espo/Core/Webhook/AddressUtil.php b/application/Espo/Core/Webhook/AddressUtil.php new file mode 100644 index 0000000000..8ccff583ee --- /dev/null +++ b/application/Espo/Core/Webhook/AddressUtil.php @@ -0,0 +1,79 @@ +. + * + * 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\Webhook; + +use Espo\Core\Utils\Config; + +/** + * @internal + */ +class AddressUtil +{ + public function __construct( + private Config $config, + ) {} + + /** + * @internal + */ + public function isAllowedUrl(string $url): bool + { + /** @var string[] $allowedAddressList */ + $allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? []; + + if (!$allowedAddressList) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + $scheme = parse_url($url, PHP_URL_SCHEME); + + if (!is_string($host)) { + return false; + } + + if (!is_int($port)) { + if ($scheme === 'https') { + $port = 443; + } else if ($scheme === 'http') { + $port = 80; + } + } + + if (!is_int($port)) { + return false; + } + + $address = $host . ':' . $port; + + return in_array($address, $allowedAddressList); + } +} diff --git a/application/Espo/Core/Webhook/Sender.php b/application/Espo/Core/Webhook/Sender.php index 537012d9dc..23c3b1cac3 100644 --- a/application/Espo/Core/Webhook/Sender.php +++ b/application/Espo/Core/Webhook/Sender.php @@ -32,6 +32,7 @@ namespace Espo\Core\Webhook; use Espo\Core\Exceptions\Error; use Espo\Core\Utils\Config; use Espo\Core\Utils\Json; +use Espo\Core\Utils\Security\UrlCheck; use Espo\Entities\Webhook; /** @@ -42,8 +43,11 @@ class Sender private const CONNECT_TIMEOUT = 5; private const TIMEOUT = 10; - public function __construct(private Config $config) - {} + public function __construct( + private Config $config, + private UrlCheck $urlCheck, + private AddressUtil $addressUtil, + ) {} /** * @param array $dataList @@ -85,6 +89,17 @@ class Sender throw new Error("Webhook does not have URL."); } + if (!$this->urlCheck->isUrl($url)) { + throw new Error("'$url' is not valid URL."); + } + + if ( + !$this->addressUtil->isAllowedUrl($url) && + !$this->urlCheck->isNotInternalUrl($url) + ) { + throw new Error("URL '$url' points to an internal host, not allowed."); + } + $handler = curl_init($url); if ($handler === false) { diff --git a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json index 7065811149..e5b3d305ed 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json @@ -21,14 +21,16 @@ "default": "Active" }, "host": { - "type": "varchar" + "type": "varchar", + "massUpdateDisabled": true }, "port": { "type": "int", "min": 0, "max": 65535, "default": 993, - "disableFormatting": true + "disableFormatting": true, + "massUpdateDisabled": true }, "security": { "type": "enum", @@ -112,14 +114,16 @@ "tooltip": true }, "smtpHost": { - "type": "varchar" + "type": "varchar", + "massUpdateDisabled": true }, "smtpPort": { "type": "int", "min": 0, "max": 65535, "default": 587, - "disableFormatting": true + "disableFormatting": true, + "massUpdateDisabled": true }, "smtpAuth": { "type": "bool", diff --git a/application/Espo/Resources/metadata/entityDefs/InboundEmail.json b/application/Espo/Resources/metadata/entityDefs/InboundEmail.json index 117593bce3..97235114f5 100644 --- a/application/Espo/Resources/metadata/entityDefs/InboundEmail.json +++ b/application/Espo/Resources/metadata/entityDefs/InboundEmail.json @@ -21,14 +21,16 @@ "default": "Active" }, "host": { - "type": "varchar" + "type": "varchar", + "massUpdateDisabled": true }, "port": { "type": "int", "min": 0, "max": 65535, "default": 993, - "disableFormatting": true + "disableFormatting": true, + "massUpdateDisabled": true }, "security": { "type": "enum", @@ -122,14 +124,16 @@ "tooltip": true }, "smtpHost": { - "type": "varchar" + "type": "varchar", + "massUpdateDisabled": true }, "smtpPort": { "type": "int", "min": 0, "max": 65535, "default": 587, - "disableFormatting": true + "disableFormatting": true, + "massUpdateDisabled": true }, "smtpAuth": { "type": "bool", diff --git a/application/Espo/Resources/metadata/entityDefs/Webhook.json b/application/Espo/Resources/metadata/entityDefs/Webhook.json index 6a01d61329..afe8a2f325 100644 --- a/application/Espo/Resources/metadata/entityDefs/Webhook.json +++ b/application/Espo/Resources/metadata/entityDefs/Webhook.json @@ -10,7 +10,10 @@ "type": "varchar", "maxLength": 512, "required": true, - "copyToClipboard": true + "copyToClipboard": true, + "validatorClassNameList": [ + "Espo\\Classes\\FieldValidators\\Webhook\\Url\\NotInternal" + ] }, "isActive": { "type": "bool", diff --git a/application/Espo/Resources/metadata/recordDefs/EmailAccount.json b/application/Espo/Resources/metadata/recordDefs/EmailAccount.json index 8942d1fd84..9504af603d 100644 --- a/application/Espo/Resources/metadata/recordDefs/EmailAccount.json +++ b/application/Espo/Resources/metadata/recordDefs/EmailAccount.json @@ -15,9 +15,11 @@ ], "beforeCreateHookClassNameList": [ "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeCreate", - "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave" + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave", + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSaveValidateHosts" ], "beforeUpdateHookClassNameList": [ - "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave" + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave", + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSaveValidateHosts" ] } diff --git a/application/Espo/Resources/metadata/recordDefs/InboundEmail.json b/application/Espo/Resources/metadata/recordDefs/InboundEmail.json index a7438a6239..62d2310488 100644 --- a/application/Espo/Resources/metadata/recordDefs/InboundEmail.json +++ b/application/Espo/Resources/metadata/recordDefs/InboundEmail.json @@ -18,5 +18,11 @@ ], "listLoaderClassNameList": [ "Espo\\Classes\\FieldProcessing\\InboundEmail\\IsSystemLoader" + ], + "beforeCreateHookClassNameList": [ + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSaveValidateHosts" + ], + "beforeUpdateHookClassNameList": [ + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSaveValidateHosts" ] } diff --git a/application/Espo/Tools/Email/Api/PostSendTest.php b/application/Espo/Tools/Email/Api/PostSendTest.php index 8c5861c9bc..4c1f5998b9 100644 --- a/application/Espo/Tools/Email/Api/PostSendTest.php +++ b/application/Espo/Tools/Email/Api/PostSendTest.php @@ -38,8 +38,10 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; +use Espo\Core\Mail\Account\Util\AddressUtil; use Espo\Core\Mail\Exceptions\NoSmtp; use Espo\Core\Mail\SmtpParams; +use Espo\Core\Utils\Security\HostCheck; use Espo\Entities\Email; use Espo\Tools\Email\SendService; use Espo\Tools\Email\TestSendData; @@ -51,7 +53,9 @@ class PostSendTest implements Action { public function __construct( private SendService $sendService, - private Acl $acl + private Acl $acl, + private HostCheck $hostCheck, + private AddressUtil $addressUtil, ) {} /** @@ -109,6 +113,13 @@ class PostSendTest implements Action ->withAuthMechanism($authMechanism); } + if ( + !$this->addressUtil->isAllowedAddress($smtpParams) && + !$this->hostCheck->isNotInternalHost($server) + ) { + throw new Forbidden("Not allowed internal host."); + } + $data = new TestSendData($emailAddress, $type, $id, $userId); $this->sendService->sendTestEmail($smtpParams, $data);