diff --git a/CRM/Core/Smarty/UserContentPolicy.php b/CRM/Core/Smarty/UserContentPolicy.php new file mode 100644 index 000000000000..f3a7bb7021ef --- /dev/null +++ b/CRM/Core/Smarty/UserContentPolicy.php @@ -0,0 +1,207 @@ + $instance]); + Civi::dispatcher()->dispatch('hook_civicrm_userContentPolicy', $event); + + return $instance; + } + + public function enable(): void { + $smarty = CRM_Core_Smarty::singleton(); + switch ($smarty->getVersion()) { + case 2: + $this->old_settings = $smarty->security_settings; + $smarty->security_settings = $this->createSmartyPolicy2($smarty); + $smarty->security = TRUE; + return; + + case 3: + case 4: + $smarty->enableSecurity($this->createSmartyPolicy34()); + return; + + case 5: + $smarty->enableSecurity($this->createSmartyPolicy5()); + return; + } + } + + public function disable(): void { + $smarty = CRM_Core_Smarty::singleton(); + switch ($smarty->getVersion()) { + case 2: + $smarty->security_settings = $this->old_settings; + $smarty->security = FALSE; + return; + + case 3: + case 4: + $smarty->disableSecurity(); + return; + + case 5: + $smarty->disableSecurity(); + return; + } + } + + protected function createSmartyPolicy2($smarty): array { + $result = $smarty->security_settings; + $result['IF_FUNCS'] = $this->php_functions; + $result['MODIFIER_FUNCS'] = $this->php_modifiers; + $result['ALLOW_CONSTANTS'] = $this->allow_constants; + $result['ALLOW_SUPER_GLOBALS'] = $this->allow_super_globals; + return $result; + } + + protected function createSmartyPolicy34(): string { + $obj = new class(NULL) extends Smarty_Security { + + public function __construct($smarty) { + parent::__construct($smarty); + + /** @var \CRM_Core_Smarty_UserContentPolicy $policy */ + $policy = Civi::service('civi.smarty.userContent'); + + $this->php_functions = $policy->php_functions; + $this->php_modifiers = $policy->php_modifiers; + $this->disabled_tags = $policy->disabled_tags; + + $this->static_classes = NULL; + $this->allow_constants = $policy->allow_constants; + $this->allow_super_globals = $policy->allow_super_globals; + } + + }; + return get_class($obj); + } + + protected function createSmartyPolicy5(): string { + + $obj = new class(NULL) extends \Smarty\Security { + + public function __construct($smarty) { + if ($smarty !== NULL) { + parent::__construct($smarty); + } + + /** @var \CRM_Core_Smarty_UserContentPolicy $policy */ + $policy = Civi::service('civi.smarty.userContent'); + + // This feels counterintuitive. Eileen thinks it may be a miscommunication. + // Functionally, consider that (a) security is enabled/disabled/enabled/disabled + // but (b) the registered plugins don't actually change. + + // foreach ($policy->php_functions as $phpFunction) { + // $smarty->registerPlugin('modifier', $phpFunction, $phpFunction); + // } + // foreach ($policy->php_modifiers as $modifier) { + // $smarty->registerPlugin('modifier', $modifier, $modifier); + // } + + $this->static_classes = NULL; + $this->allow_constants = $policy->allow_constants; + $this->allow_super_globals = $policy->allow_super_globals; + } + + }; + return get_class($obj); + } + + /** + * Smarty 3+4 have option to disable tags in secure mode, but Smarty 2 doesn't. + * So for any potentially-sensitive tags, we support an alternate mechanism to check access. + * + * @param string $tag + * @return void + * @throws \Exception + */ + public static function assertTagAllowed(string $tag): void { + if (!\Civi\Core\Container::isContainerBooted()) { + // We don't run user content before the system has booted. + // So the details here are kind of academic. + // Main thing: don't force a premature bootstrap. + $disabledTags = ['crmAPI']; + } + else { + $smarty = CRM_Core_Smarty::singleton(); + $hasSecurity = ($smarty->getVersion() > 2) ? (bool) $smarty->security_policy : $smarty->security; + if (!$hasSecurity) { + return; + } + + $policy = Civi::service('civi.smarty.userContent'); + $disabledTags = $policy->disabled_tags; + } + + if (in_array($tag, $disabledTags)) { + throw new \Exception("Tag '{$tag}' is not allowed in secure mode."); + } + } + +} diff --git a/CRM/Core/Smarty/plugins/function.crmAPI.php b/CRM/Core/Smarty/plugins/function.crmAPI.php index 48e759dd88bb..1057905738f1 100644 --- a/CRM/Core/Smarty/plugins/function.crmAPI.php +++ b/CRM/Core/Smarty/plugins/function.crmAPI.php @@ -22,6 +22,8 @@ * @return string|void */ function smarty_function_crmAPI($params, &$smarty) { + CRM_Core_Smarty_UserContentPolicy::assertTagAllowed('crmAPI'); + if (!array_key_exists('entity', $params)) { $smarty->trigger_error("assign: missing 'entity' parameter"); return "crmAPI: missing 'entity' parameter"; diff --git a/CRM/Utils/String.php b/CRM/Utils/String.php index 82d36093ad77..26ce1babede7 100644 --- a/CRM/Utils/String.php +++ b/CRM/Utils/String.php @@ -1015,10 +1015,11 @@ public static function stringContainsTokens(string $string) { * many times it is run. This compares to it otherwise creating one file for every parsed string. * * @param string $templateString + * @param array $templateVars * * @return string */ - public static function parseOneOffStringThroughSmarty($templateString) { + public static function parseOneOffStringThroughSmarty($templateString, $templateVars = []) { if (!CRM_Utils_String::stringContainsTokens($templateString)) { // Skip expensive smarty processing. return $templateString; @@ -1026,12 +1027,53 @@ public static function parseOneOffStringThroughSmarty($templateString) { $smarty = CRM_Core_Smarty::singleton(); $cachingValue = $smarty->caching; $smarty->caching = 0; + $useSecurityPolicy = ($smarty->getVersion() > 2) ? !$smarty->security_policy : !$smarty->security; + // For Smarty v2, policy is applied at lower level. + if ($useSecurityPolicy) { + // $smarty->enableSecurity('CRM_Core_Smarty_Security'); + Civi::service('civi.smarty.userContent')->enable(); + } $smarty->assign('smartySingleUseString', $templateString); - // Do not escape the smartySingleUseString as that is our smarty template - // and is likely to contain html. - $templateString = (string) $smarty->fetch('string:{eval var=$smartySingleUseString|smarty:nodefaults}'); - $smarty->caching = $cachingValue; - $smarty->assign('smartySingleUseString', NULL); + try { + // Do not escape the smartySingleUseString as that is our smarty template + // and is likely to contain html. + // The file name generated by + // 'string:{eval var=$smartySingleUseString|smarty:nodefaults}' + // is invalid in Windows, causing failure. + // Adding this is preparatory to smarty 3. The original PR failed some + // tests so we check for the function. + if (!function_exists('smarty_function_eval') && (!defined('SMARTY_DIR') || !file_exists(SMARTY_DIR . '/plugins/function.eval.php'))) { + if (!empty($templateVars)) { + $templateString = (string) $smarty->fetchWith('eval:' . $templateString, $templateVars); + } + else { + $templateString = (string) $smarty->fetch('eval:' . $templateString); + } + } + else { + if (!empty($templateVars)) { + $templateString = (string) $smarty->fetchWith('string:{eval var=$smartySingleUseString|smarty:nodefaults}', $templateVars); + } + else { + $templateString = (string) $smarty->fetch('string:{eval var=$smartySingleUseString|smarty:nodefaults}'); + } + } + } + catch (Exception $e) { + \Civi::log('smarty')->info('parsing smarty template {template}', [ + 'template' => $templateString, + ]); + throw new \CRM_Core_Exception('Message was not parsed due to invalid smarty syntax : ' . $e->getMessage() . ((CIVICRM_UF === 'UnitTest' || CRM_Utils_Constant::value('SMARTY_DEBUG_STRINGS')) ? $templateString : '')); + } + finally { + $smarty->caching = $cachingValue; + $smarty->assign('smartySingleUseString'); + restore_error_handler(); + if ($useSecurityPolicy) { + // $smarty->disableSecurity(); + Civi::service('civi.smarty.userContent')->disable(); + } + } return $templateString; } diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php index 0abc53a3fd52..cfc10296d862 100644 --- a/Civi/Api4/Generic/AbstractAction.php +++ b/Civi/Api4/Generic/AbstractAction.php @@ -533,7 +533,7 @@ protected function evaluateCondition($expr, $vars) { throw new \API_Exception('Illegal character in expression'); } $tpl = "{if $expr}1{else}0{/if}"; - return (bool) trim(\CRM_Core_Smarty::singleton()->fetchWith('string:' . $tpl, $vars)); + return (bool) trim(\CRM_Utils_String::parseOneOffStringThroughSmarty($tpl, $vars)); } /**