diff --git a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php index d2fdcb8e315fe..f234ce8312297 100644 --- a/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php +++ b/vendor/magento/module-email/Block/Adminhtml/Template/Preview.php @@ -7,6 +7,10 @@ namespace Magento\Email\Block\Adminhtml\Template; +use Magento\Email\Model\Template\Validator as TemplateValidator; +use Magento\Framework\Validation\ValidationException; +use Magento\Framework\App\ObjectManager; + /** * Email template preview block. * @@ -30,20 +34,29 @@ class Preview extends \Magento\Backend\Block\Widget */ protected $profilerName = 'email_template_proccessing'; + /** + * @var TemplateValidator + */ + private $templateValidator; + /** * @param \Magento\Backend\Block\Template\Context $context * @param \Magento\Framework\Filter\Input\MaliciousCode $maliciousCode * @param \Magento\Email\Model\TemplateFactory $emailFactory * @param array $data + * @param TemplateValidator $templateValidator */ public function __construct( \Magento\Backend\Block\Template\Context $context, \Magento\Framework\Filter\Input\MaliciousCode $maliciousCode, \Magento\Email\Model\TemplateFactory $emailFactory, - array $data = [] + array $data = [], + ?TemplateValidator $templateValidator = null ) { $this->_maliciousCode = $maliciousCode; $this->_emailFactory = $emailFactory; + $this->templateValidator = $templateValidator ?: + ObjectManager::getInstance()->get(TemplateValidator::class); parent::__construct($context, $data); } @@ -64,10 +77,23 @@ protected function _toHtml() if ($id = (int)$request->getParam('id')) { $template->load($id); } else { + $rawText = $request->getParam('text'); + $rawText = is_string($rawText) ? $rawText : ''; + $rawStyles = $request->getParam('styles'); + $rawStyles = is_string($rawStyles) ? $rawStyles : ''; + try { + $this->templateValidator->validate($rawText); + if ($rawStyles !== '') { + $this->templateValidator->validate($rawStyles); + } + } catch (ValidationException $e) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Template content validation failed. Please review template syntax.') + ); + } $template->setTemplateType($request->getParam('type')); - $template->setTemplateText($this->_maliciousCode->filter($request->getParam('text'))); - $template->setTemplateStyles($request->getParam('styles')); - + $template->setTemplateText($this->_maliciousCode->filter($rawText)); + $template->setTemplateStyles($rawStyles); } \Magento\Framework\Profiler::start($this->profilerName); diff --git a/vendor/magento/module-email/Model/AbstractTemplate.php b/vendor/magento/module-email/Model/AbstractTemplate.php index a3e328bb2eb30..dd037905ae732 100644 --- a/vendor/magento/module-email/Model/AbstractTemplate.php +++ b/vendor/magento/module-email/Model/AbstractTemplate.php @@ -16,6 +16,8 @@ use Magento\Store\Model\Store; use Magento\MediaStorage\Helper\File\Storage\Database; use Magento\Framework\Validation\ValidationException; +use Magento\Email\Model\Template\Validator as TemplateValidator; +use Magento\Framework\App\ObjectManager; /** * Template model class. @@ -169,6 +171,11 @@ abstract class AbstractTemplate extends AbstractModel implements TemplateTypesIn */ private $fileStorageDatabase; + /** + * @var TemplateValidator + */ + private $templateValidator; + /** * @param \Magento\Framework\Model\Context $context * @param \Magento\Framework\View\DesignInterface $design @@ -184,6 +191,7 @@ abstract class AbstractTemplate extends AbstractModel implements TemplateTypesIn * @param \Magento\Framework\UrlInterface $urlModel * @param array $data * @param Database $fileStorageDatabase + * @param TemplateValidator|null $templateValidator * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ @@ -201,7 +209,8 @@ public function __construct( \Magento\Framework\Filter\FilterManager $filterManager, \Magento\Framework\UrlInterface $urlModel, array $data = [], - Database $fileStorageDatabase = null + Database $fileStorageDatabase = null, + ?TemplateValidator $templateValidator = null ) { $this->design = $design; $this->area = isset($data['area']) ? $data['area'] : null; @@ -217,6 +226,8 @@ public function __construct( $this->urlModel = $urlModel; $this->fileStorageDatabase = $fileStorageDatabase ?: \Magento\Framework\App\ObjectManager::getInstance()->get(Database::class); + $this->templateValidator = $templateValidator ?: + ObjectManager::getInstance()->get(TemplateValidator::class); parent::__construct($context, $registry, null, null, $data); } @@ -773,7 +784,7 @@ public function beforeSave() $templateText = $this->getTemplateText(); if ($templateText) { try { - $this->validateTemplateText($templateText); + $this->templateValidator->validate($templateText); } catch (ValidationException $exception) { throw new ValidationException( __('Content field contains restricted HTML elements. %1', $exception->getMessage()), @@ -781,27 +792,18 @@ public function beforeSave() ); } } + $templateStyles = $this->getTemplateStyles(); + if ($templateStyles) { + try { + $this->templateValidator->validate($templateStyles); + } catch (ValidationException $exception) { + throw new ValidationException( + __('Styles field validation failed. %1', $exception->getMessage()), + $exception + ); + } + } parent::beforeSave(); return $this; } - - /** - * Validate template text - * - * @param string $content - * @throws ValidationException - */ - private function validateTemplateText(string $content): void - { - $expression = '/\b(?:src|href|action)\s*=\s*([\'"])(?:[^\'"]*?)https?:\/\/[^\/\s]+\/' . - '(?:[^\'"]*?)\{\{\s*[^}]+\s*\}\}(?:[^\'"]*?)\1/iu'; - - if (preg_match($expression, $content)) { - throw new ValidationException( - __( - 'Template directives are not allowed in external URLs.' - ) - ); - } - } } diff --git a/vendor/magento/module-email/Model/Template/Filter.php b/vendor/magento/module-email/Model/Template/Filter.php index 36e967da160c0..ac6c768e87f22 100644 --- a/vendor/magento/module-email/Model/Template/Filter.php +++ b/vendor/magento/module-email/Model/Template/Filter.php @@ -976,7 +976,14 @@ public function inlinecssDirective($construction) // If this template is a child of another template, skip processing so that the parent template will process // this directive. This is important as CSS inlining must operate on the entire HTML document. if ($this->isChildTemplate()) { - return $construction[0]; + try { + return $this->deferToParent($construction[0]); + } catch (\Exception $e) { + throw new MailException( + __('Unable to defer the "inlinecss" directive for later processing.'), + $e + ); + } } $params = $this->getParameters($construction[2]); diff --git a/vendor/magento/module-email/Model/Template/Validator.php b/vendor/magento/module-email/Model/Template/Validator.php new file mode 100644 index 0000000000000..3173d7a57d8ab --- /dev/null +++ b/vendor/magento/module-email/Model/Template/Validator.php @@ -0,0 +1,96 @@ +`])+))/iu'; + + /** + * Regular expressions for CSS url argument containing a template directive + * + * @var string + */ + private const CSS_URL_VALUE_EXPRESSION = + '/\burl\(\s*(?|"([^"]*\{\{[^}]*\}\}[^"]*)"' . + '|\'([^\']*\{\{[^}]*\}\}[^\']*)\'' . + '|((?:\{\{[^}]*\}\}|[^\s"\')])+))\s*\)/iu'; + + /** + * Validate template content + * + * @param string $content + * + * @throws ValidationException + */ + public function validate(string $content): void + { + foreach ([self::ATTRIBUTE_VALUE_EXPRESSION, self::CSS_URL_VALUE_EXPRESSION] as $expression) { + $matchCount = preg_match_all($expression, $content, $matches); + if ($matchCount === false) { + throw new ValidationException( + __('Template content validation failed. Please review template syntax.') + ); + } + foreach ($matches[1] as $value) { + if ($this->isForbiddenUrlValue($value)) { + throw new ValidationException( + __('Template content validation failed. Please review template syntax.') + ); + } + } + } + } + + /** + * Check whether a URL-bearing value resolves to an absolute external target + * + * @param string $value + * @return bool + */ + private function isForbiddenUrlValue(string $value): bool + { + $normalized = $this->normalizeUrlValue($value); + if (strpos($normalized, '{{') === false) { + return false; + } + + return preg_match('#^https?:/#i', $normalized) === 1 + || strncmp($normalized, '//', 2) === 0; + } + + /** + * Normalize a URL-bearing value + * + * @param string $value + * @return string + */ + private function normalizeUrlValue(string $value): string + { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $value = str_ireplace(['%2f', '%5c'], ['/', '\\'], $value); + $value = str_replace('\\', '/', $value); + + return preg_replace('/[\x00-\x20]+/', '', $value) ?? $value; + } +}