diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 3042b9a594c..cb91423dde1 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -1041,7 +1041,7 @@ public function autoGeneratedTitle() // Since the slug is generated from the title, we'll avoid augmenting // the slug which could result in an infinite loop in some cases. - $title = $this->withLocale($this->site()->lang(), fn () => (string) Antlers::parseUserContent($format, $this->augmented()->except('slug')->all())); + $title = $this->withLocale($this->site()->lang(), fn () => (string) Antlers::parse($format, $this->augmented()->except('slug')->all())); return trim($title); } @@ -1065,7 +1065,7 @@ private function resolvePreviewTargetUrl($format) }, $format); } - return (string) Antlers::parseUserContent($format, array_merge($this->routeData(), [ + return (string) Antlers::parse($format, array_merge($this->routeData(), [ 'config' => Cascade::config(), 'site' => $this->site(), 'uri' => $this->uri(), diff --git a/src/Facades/Antlers.php b/src/Facades/Antlers.php index e29d69889bd..c6b285ce9c0 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -9,9 +9,8 @@ /** * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) - * @method static AntlersString parse(string $str, array $variables = []) - * @method static AntlersString parseUserContent(string $str, array $variables = []) - * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = []) + * @method static AntlersString parse(string $str, array $variables = [], bool $trusted = false) + * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = [], bool $trusted = false) * @method static array identifiers(string $content) * * @see \Statamic\View\Antlers\Antlers diff --git a/src/Facades/Endpoint/Parse.php b/src/Facades/Endpoint/Parse.php index 6f447d96541..1f7865da6d0 100644 --- a/src/Facades/Endpoint/Parse.php +++ b/src/Facades/Endpoint/Parse.php @@ -17,12 +17,12 @@ class Parse * @param string $str String to parse * @param array $variables Variables to use * @param array $context Contextual variables to also use - * @param bool $php Whether PHP should be allowed + * @param bool $trusted Whether the template should be treated as trusted * @return string */ - public function template($str, $variables = [], $context = [], $php = false) + public function template($str, $variables = [], $context = [], $trusted = false) { - return Antlers::parse($str, $variables, $context, $php); + return Antlers::parse($str, array_merge($variables, $context), $trusted); } /** @@ -32,12 +32,12 @@ public function template($str, $variables = [], $context = [], $php = false) * @param array $data Variables to use, in a multidimensional array * @param bool $supplement Whether to supplement with contextual values * @param array $context Contextual variables to also use - * @param bool $php Whether PHP should be allowed + * @param bool $trusted Whether the template should be treated as trusted * @return string */ - public function templateLoop($content, $data, $supplement = true, $context = [], $php = false) + public function templateLoop($content, $data, $supplement = true, $context = [], $trusted = false) { - return Antlers::parseLoop($content, $data, $supplement, $context, $php); + return Antlers::parseLoop($content, $data, $supplement, $context, $trusted); } /** diff --git a/src/Facades/Parse.php b/src/Facades/Parse.php index e7e44198566..eb2de226837 100644 --- a/src/Facades/Parse.php +++ b/src/Facades/Parse.php @@ -6,8 +6,8 @@ use Statamic\View\Antlers\AntlersString; /** - * @method static AntlersString template($str, $variables = [], $context = [], $php = false) - * @method static string templateLoop($content, $data, $supplement = true, $context = [], $php = false) + * @method static AntlersString template($str, $variables = [], $context = [], $trusted = false) + * @method static string templateLoop($content, $data, $supplement = true, $context = [], $trusted = false) * @method static array YAML($str) * @method static array frontMatter($string) * @method static mixed env($val) diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 0ce40bf5671..076bc20b279 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -245,7 +245,7 @@ protected function parseConfig(array $config) return collect($config)->map(function ($value) { $value = Parse::env($value); // deprecated - return (string) Antlers::parseUserContent($value, array_merge( + return (string) Antlers::parse($value, array_merge( ['config' => Cascade::config()], $this->getGlobalsData(), $this->submissionData, diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index b6f8e5449b9..827d121d4ed 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -31,6 +31,7 @@ use Statamic\Support\Dumper; use Statamic\Support\Html; use Statamic\Support\Str; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Stringy\StaticStringy as Stringy; class CoreModifiers extends Modifier @@ -119,7 +120,9 @@ public function ampersandList($value, $params) */ public function antlers($value, $params, $context) { - return (string) Antlers::parse($value, $context); + $trusted = Arr::get($params, 0) === 'trusted' && ! GlobalRuntimeState::$isEvaluatingUserData; + + return (string) Antlers::parse($value, $context, $trusted); } /** @@ -1872,7 +1875,7 @@ public function partial($value, $params, $context) $partial = 'partials/'.$name.'.html'; - return Parse::template(File::disk('resources')->get($partial), $value); + return Parse::template(File::disk('resources')->get($partial), $value, trusted: true); } /** diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index b16050a7f36..3da868a5c41 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -102,6 +102,99 @@ private function registerAntlers() $runtimeConfig->guardedContentVariablePatterns = config('statamic.antlers.guardedContentVariables', []); $runtimeConfig->guardedContentTagPatterns = config('statamic.antlers.guardedContentTags', []); $runtimeConfig->guardedContentModifiers = config('statamic.antlers.guardedContentModifiers', []); + $runtimeConfig->allowedContentTagPatterns = config('statamic.antlers.allowedContentTags', [ + 'obfuscate:*', + 'trans:*', + 'trans_choice:*', + 'widont:*', + ]); + $runtimeConfig->allowedContentModifiers = config('statamic.antlers.allowedContentModifiers', [ + 'add_query_param', + 'add_slashes', + 'ascii', + 'at', + 'background_position', + 'bool_string', + 'camelize', + 'cdata', + 'ceil', + 'collapse_whitespace', + 'count_substring', + 'dashify', + 'decode', + 'deslugify', + 'divide', + 'ends_with', + 'ensure_left', + 'ensure_right', + 'entities', + 'explode', + 'extension', + 'floor', + 'format', + 'format_number', + 'format_translated', + 'has_lower_case', + 'has_upper_case', + 'headline', + 'hex_to_rgb', + 'insert', + 'is_alpha', + 'is_alphanumeric', + 'is_blank', + 'is_email', + 'is_external_url', + 'is_json', + 'is_lowercase', + 'is_numeric', + 'is_uppercase', + 'is_url', + 'join', + 'kebab', + 'lcfirst', + 'localize', + 'upper', + 'lower', + 'md5', + 'mod', + 'multiply', + 'obfuscate', + 'obfuscate_email', + 'parse_url', + 'pathinfo', + 'rawurlencode', + 'remove_left', + 'remove_query_param', + 'remove_right', + 'replace', + 'round', + 'safe_truncate', + 'sanitize', + 'slugify', + 'snake', + 'starts_with', + 'str_pad', + 'str_pad_both', + 'str_pad_left', + 'str_pad_right', + 'strip_tags', + 'studly', + 'subtract', + 'substr', + 'sum', + 'swap_case', + 'title', + 'to_bool', + 'to_string', + 'trans', + 'trans_choice', + 'trim', + 'truncate', + 'ucfirst', + 'urldecode', + 'urlencode', + 'widont', + ]); $runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false); $runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false); diff --git a/src/Tags/Tags.php b/src/Tags/Tags.php index b001cf348e3..4f32b7024d7 100644 --- a/src/Tags/Tags.php +++ b/src/Tags/Tags.php @@ -10,6 +10,7 @@ use Statamic\Facades\Antlers; use Statamic\Support\Arr; use Statamic\Support\Traits\Hookable; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; abstract class Tags { @@ -207,8 +208,10 @@ public function parse($data = []) } return Antlers::usingParser($this->parser, function ($antlers) use ($data) { + $trusted = ! GlobalRuntimeState::$isEvaluatingUserData; + return $antlers - ->parse($this->content, array_merge($this->context->all(), $data)) + ->parse($this->content, array_merge($this->context->all(), $data), $trusted) ->withoutExtractions(); }); } @@ -244,8 +247,10 @@ public function parseLoop($data, $supplement = true) } return Antlers::usingParser($this->parser, function ($antlers) use ($data, $supplement) { + $trusted = ! GlobalRuntimeState::$isEvaluatingUserData; + return $antlers - ->parseLoop($this->content, $data, $supplement, $this->context->all()) + ->parseLoop($this->content, $data, $supplement, $this->context->all(), $trusted) ->withoutExtractions(); }); } diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index b1dc611cfbc..606ac973cb0 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -27,20 +27,16 @@ public function usingParser(Parser $parser, Closure $callback) return $contents; } - public function parse($str, $variables = []) + public function parse($str, $variables = [], $trusted = false) { - return $this->parser()->parse($str, $variables); - } - - public function parseUserContent($str, $variables = []) - { - $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; - GlobalRuntimeState::$isEvaluatingUserData = true; + $parser = $this->parser(); + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; try { - return $this->parser()->parse($str, $variables); + return $parser->parse($str, $variables); } finally { - GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = $previousState; } } @@ -51,11 +47,12 @@ public function parseUserContent($str, $variables = []) * @param array $data * @param bool $supplement * @param array $context + * @param bool $trusted * @return string */ - public function parseLoop($content, $data, $supplement = true, $context = []) + public function parseLoop($content, $data, $supplement = true, $context = [], $trusted = false) { - return new AntlersLoop($this->parser(), $content, $data, $supplement, $context); + return new AntlersLoop($this->parser(), $content, $data, $supplement, $context, $trusted); } public function identifiers(string $content): array diff --git a/src/View/Antlers/AntlersLoop.php b/src/View/Antlers/AntlersLoop.php index d3e64222e57..4af1a1b4bc9 100644 --- a/src/View/Antlers/AntlersLoop.php +++ b/src/View/Antlers/AntlersLoop.php @@ -2,6 +2,8 @@ namespace Statamic\View\Antlers; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; + class AntlersLoop extends AntlersString { protected $parser; @@ -9,17 +11,31 @@ class AntlersLoop extends AntlersString protected $variables; protected $supplement; protected $context; + protected $trusted; - public function __construct($parser, $string, $variables, $supplement, $context) + public function __construct($parser, $string, $variables, $supplement, $context, $trusted = false) { $this->parser = $parser; $this->string = $string; $this->variables = $variables; $this->supplement = $supplement; $this->context = $context; + $this->trusted = $trusted; } public function __toString() + { + $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = ! $this->trusted; + + try { + return $this->renderLoopContent(); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; + } + } + + private function renderLoopContent() { $total = count($this->variables); $i = 0; diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index a0d2b481508..e0ebc3b05ca 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -77,7 +77,7 @@ class GlobalRuntimeState * * @var bool */ - public static $isEvaluatingUserData = false; + public static $isEvaluatingUserData = true; public static $isEvaluatingData = false; @@ -163,6 +163,13 @@ public static function mergeTagRuntimeAssignments($assignments) */ public static $bannedContentTagPaths = []; + /** + * A list of all allowed content tag paths. + * + * @var string[] + */ + public static $allowedContentTagPaths = []; + /** * A list of all invalid modifier paths. * @@ -177,6 +184,13 @@ public static function mergeTagRuntimeAssignments($assignments) */ public static $bannedContentModifierPaths = []; + /** + * A list of all allowed content modifier paths. + * + * @var string[] + */ + public static $allowedContentModifierPaths = []; + /** * Controls if PHP is evaluated in user content. * @@ -233,7 +247,7 @@ public static function resetGlobalState() self::$yieldCount = 0; self::$yieldStacks = []; self::$abandonedNodes = []; - self::$isEvaluatingUserData = false; + self::$isEvaluatingUserData = true; self::$isEvaluatingData = false; self::$userContentEvalState = null; diff --git a/src/View/Antlers/Language/Runtime/ModifierManager.php b/src/View/Antlers/Language/Runtime/ModifierManager.php index f715550bbaf..8f7871555c7 100644 --- a/src/View/Antlers/Language/Runtime/ModifierManager.php +++ b/src/View/Antlers/Language/Runtime/ModifierManager.php @@ -38,11 +38,35 @@ public static function guardRuntimeModifier($modifierName) self::$lastModifierName = $modifierName; if (GlobalRuntimeState::$isEvaluatingUserData) { + $allowList = GlobalRuntimeState::$allowedContentModifierPaths; $guardList = GlobalRuntimeState::$bannedContentModifierPaths; - } else { - $guardList = GlobalRuntimeState::$bannedModifierPaths; + + $isAllowed = Str::is($allowList, $modifierName); + $isBlocked = ! empty($guardList) && Str::is($guardList, $modifierName); + + if (! $isAllowed || $isBlocked) { + Log::warning('Runtime Access Violation: '.$modifierName, [ + 'modifier' => $modifierName, + 'file' => GlobalRuntimeState::$currentExecutionFile, + 'trace' => GlobalRuntimeState::$templateFileStack, + ]); + + if (GlobalRuntimeState::$throwErrorOnAccessViolation) { + throw ErrorFactory::makeRuntimeError( + AntlersErrorCodes::RUNTIME_PROTECTED_MODIFIER_ACCESS, + null, + 'Protected tag access.' + ); + } + + return false; + } + + return true; } + $guardList = GlobalRuntimeState::$bannedModifierPaths; + if (empty($guardList)) { return true; } diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 4450d838d32..ba675fa799b 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -1075,11 +1075,35 @@ public function evaluateDeferredVariable(AbstractNode $deferredNode) public function guardRuntimeTag($tagCheck) { if (GlobalRuntimeState::$isEvaluatingUserData) { + $allowList = GlobalRuntimeState::$allowedContentTagPaths; $guardList = GlobalRuntimeState::$bannedContentTagPaths; - } else { - $guardList = GlobalRuntimeState::$bannedTagPaths; + + $isAllowed = Str::is($allowList, $tagCheck); + $isBlocked = ! empty($guardList) && Str::is($guardList, $tagCheck); + + if (! $isAllowed || $isBlocked) { + Log::warning('Runtime Access Violation: '.$tagCheck, [ + 'tag' => $tagCheck, + 'file' => GlobalRuntimeState::$currentExecutionFile, + 'trace' => GlobalRuntimeState::$templateFileStack, + ]); + + if (GlobalRuntimeState::$throwErrorOnAccessViolation) { + throw ErrorFactory::makeRuntimeError( + AntlersErrorCodes::RUNTIME_PROTECTED_TAG_ACCESS, + null, + 'Protected tag access.' + ); + } + + return false; + } + + return true; } + $guardList = GlobalRuntimeState::$bannedTagPaths; + if (empty($guardList)) { return true; } diff --git a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php index 548be3452f0..8226380984f 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php +++ b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php @@ -84,6 +84,13 @@ class RuntimeConfiguration */ public $guardedContentTagPatterns = []; + /** + * A list of all allowed content tag patterns. + * + * @var string[] + */ + public $allowedContentTagPatterns = []; + /** * A list of all invalid modifier patterns. * @@ -98,6 +105,13 @@ class RuntimeConfiguration */ public $guardedContentModifiers = []; + /** + * A list of all allowed content modifier patterns. + * + * @var string[] + */ + public $allowedContentModifiers = []; + /** * Indicates if PHP Code should be evaluated in user content. * diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index 4d7d32461ab..9344ca09dbd 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -140,8 +140,10 @@ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) GlobalRuntimeState::$bannedContentVarPaths = $configuration->guardedContentVariablePatterns; GlobalRuntimeState::$bannedTagPaths = $configuration->guardedTagPatterns; GlobalRuntimeState::$bannedContentTagPaths = $configuration->guardedContentTagPatterns; + GlobalRuntimeState::$allowedContentTagPaths = $configuration->allowedContentTagPatterns; GlobalRuntimeState::$bannedModifierPaths = $configuration->guardedModifiers; GlobalRuntimeState::$bannedContentModifierPaths = $configuration->guardedContentModifiers; + GlobalRuntimeState::$allowedContentModifierPaths = $configuration->allowedContentModifiers; $this->nodeProcessor->setRuntimeConfiguration($configuration); @@ -688,10 +690,11 @@ private function cloneRuntimeParser() $this->antlersLexer, $this->antlersParser ))->allowPhp($this->allowPhp); - // If we are evaluating a tag's scope, we still - // want the overall parser instances to be - // isolated, but we also need the Cascade. - if (GlobalRuntimeState::$evaulatingTagContents) { + foreach ($this->preParsers as $preParser) { + $parser->preparse($preParser); + } + + if ($this->cascade != null) { $parser->cascade($this->cascade); } @@ -758,7 +761,22 @@ public function cascade($cascade) public function parseView($view, $text, $data = []) { + $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; + $existingView = $this->view; + try { + return $this->renderViewContent($view, $text, $data); + } finally { + $this->view = $existingView; + array_pop(GlobalRuntimeState::$templateFileStack); + GlobalRuntimeState::$currentExecutionFile = $this->view; + GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; + } + } + + private function renderViewContent($view, $text, $data = []) + { $this->view = $view; GlobalRuntimeState::$templateFileStack[] = [$view, null]; @@ -776,15 +794,7 @@ public function parseView($view, $text, $data = []) 'view' => $this->cascade->getViewData($view), ]); - $parsed = $this->renderText($text, $data); - - $this->view = $existingView; - - array_pop(GlobalRuntimeState::$templateFileStack); - - GlobalRuntimeState::$currentExecutionFile = $this->view; - - return $parsed; + return $this->renderText($text, $data); } public function injectNoparse($text) diff --git a/tests/Antlers/ParseUserContentTest.php b/tests/Antlers/ParseUserContentTest.php deleted file mode 100644 index 5177ee45152..00000000000 --- a/tests/Antlers/ParseUserContentTest.php +++ /dev/null @@ -1,91 +0,0 @@ -assertSame( - (string) Antlers::parse('Hello {{ name }}!', ['name' => 'Jason']), - (string) Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']) - ); - } - - #[Test] - public function it_blocks_php_nodes_in_user_content_mode() - { - Log::shouldReceive('warning') - ->once() - ->with('PHP Node evaluated in user content: {{? echo Str::upper(\'hello\') ?}}', \Mockery::type('array')); - - $result = (string) Antlers::parseUserContent('Text: {{? echo Str::upper(\'hello\') ?}}'); - - $this->assertSame('Text: ', $result); - } - - #[Test] - public function it_blocks_method_calls_in_user_content_mode() - { - Log::shouldReceive('warning') - ->once() - ->with('Method call evaluated in user content.', \Mockery::type('array')); - - $result = (string) Antlers::parseUserContent('{{ object:method("hello") }}', [ - 'object' => new ClassOne(), - ]); - - $this->assertSame('', $result); - } - - #[Test] - public function it_restores_user_data_flag_after_successful_parse() - { - GlobalRuntimeState::$isEvaluatingUserData = false; - - Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']); - - $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); - } - - #[Test] - public function it_restores_user_data_flag_after_parse_exceptions() - { - GlobalRuntimeState::$isEvaluatingUserData = false; - $parser = \Mockery::mock(Parser::class); - $parser->shouldReceive('parse') - ->once() - ->andThrow(new \RuntimeException('Failed to parse user content.')); - - try { - Antlers::usingParser($parser, function ($antlers) { - $antlers->parseUserContent('Hello {{ name }}', ['name' => 'Jason']); - }); - - $this->fail('Expected RuntimeException to be thrown.'); - } catch (\RuntimeException $exception) { - $this->assertSame('Failed to parse user content.', $exception->getMessage()); - } - - $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); - } -} diff --git a/tests/Antlers/ParserTestCase.php b/tests/Antlers/ParserTestCase.php index a51e64bed02..d6dacb7eebc 100644 --- a/tests/Antlers/ParserTestCase.php +++ b/tests/Antlers/ParserTestCase.php @@ -163,9 +163,10 @@ protected function parseTemplate($template) return $documentParser->getRenderNodes(); } - protected function parser($data = [], $withCoreTagsAndModifiers = false) + protected function parser($data = [], $withCoreTagsAndModifiers = false, $trusted = true) { GlobalRuntimeState::resetGlobalState(); + GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; $documentParser = new DocumentParser(); $loader = new Loader(); @@ -184,9 +185,10 @@ protected function parser($data = [], $withCoreTagsAndModifiers = false) return new RuntimeParser($documentParser, $processor, new AntlersLexer(), new LanguageParser()); } - protected function renderStringWithConfiguration($text, RuntimeConfiguration $config, $data = [], $withCoreTagsAndModifiers = false) + protected function renderStringWithConfiguration($text, RuntimeConfiguration $config, $data = [], $withCoreTagsAndModifiers = false, $trusted = true) { GlobalRuntimeState::resetGlobalState(); + GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; $documentParser = new DocumentParser(); $loader = new Loader(); @@ -212,10 +214,11 @@ protected function renderStringWithConfiguration($text, RuntimeConfiguration $co return (string) $runtimeParser->parse($text, $data); } - protected function renderString($text, $data = [], $withCoreTagsAndModifiers = false) + protected function renderString($text, $data = [], $withCoreTagsAndModifiers = false, $trusted = true) { ModifierManager::$statamicModifiers = null; GlobalRuntimeState::resetGlobalState(); + GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; $documentParser = new DocumentParser(); $loader = new Loader(); @@ -253,6 +256,18 @@ protected function getParsedRuntimeNodes($text) } protected function getBoolResult($text, $data) + { + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; + + try { + return $this->_getBoolResult($text, $data); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousState; + } + } + + private function _getBoolResult($text, $data) { // Create a wrapper region we can get a node from. $nodeText = '{{ '.$text.' }}'; @@ -272,6 +287,18 @@ protected function getBoolResult($text, $data) } protected function evaluateRaw($text, $data = []) + { + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; + + try { + return $this->_evaluateRaw($text, $data); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousState; + } + } + + private function _evaluateRaw($text, $data = []) { $text = StringUtilities::normalizeLineEndings($text); @@ -299,6 +326,18 @@ protected function evaluateRaw($text, $data = []) } protected function evaluateBoth($text, $data = []) + { + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; + + try { + return $this->_evaluateBoth($text, $data); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousState; + } + } + + private function _evaluateBoth($text, $data = []) { // Create a wrapper region we can get a node from. $nodeText = '{{ '.$text.' }}'; @@ -324,6 +363,18 @@ protected function evaluateBoth($text, $data = []) } protected function evaluate($text, $data = []) + { + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; + + try { + return $this->_evaluate($text, $data); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousState; + } + } + + private function _evaluate($text, $data = []) { // Create a wrapper region we can get a node from. $nodeText = '{{ '.$text.' }}'; diff --git a/tests/Antlers/Runtime/ContentAllowListTest.php b/tests/Antlers/Runtime/ContentAllowListTest.php new file mode 100644 index 00000000000..42f70c27ca5 --- /dev/null +++ b/tests/Antlers/Runtime/ContentAllowListTest.php @@ -0,0 +1,207 @@ +makeAntlersTextValue('{{ title | upper }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'title' => 'hello', + ], true, true); + + $this->assertSame('HELLO', $result); + } + + #[Test] + public function disallowed_modifier_is_blocked_in_user_content() + { + GlobalRuntimeState::$allowedContentModifierPaths = ['upper']; + + $value = $this->makeAntlersTextValue('{{ title | lower }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'title' => 'HELLO', + ], true, true); + + $this->assertSame('HELLO', $result); + } + + #[Test] + public function empty_modifier_allow_list_blocks_all_modifiers_in_user_content() + { + GlobalRuntimeState::$allowedContentModifierPaths = []; + + $value = $this->makeAntlersTextValue('{{ title | upper }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'title' => 'hello', + ], true, true); + + $this->assertSame('hello', $result); + } + + #[Test] + public function modifier_block_list_overrides_modifier_allow_list_in_user_content() + { + GlobalRuntimeState::$allowedContentModifierPaths = ['upper']; + GlobalRuntimeState::$bannedContentModifierPaths = ['upper']; + + $value = $this->makeAntlersTextValue('{{ title | upper }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'title' => 'hello', + ], true, true); + + $this->assertSame('hello', $result); + } + + #[Test] + public function disallowed_modifier_throws_when_access_violations_are_enabled() + { + GlobalRuntimeState::$allowedContentModifierPaths = ['upper']; + GlobalRuntimeState::$throwErrorOnAccessViolation = true; + + $this->expectException(RuntimeException::class); + + $value = $this->makeAntlersTextValue('{{ title | lower }}'); + $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + 'title' => 'HELLO', + ], true, true); + } + + #[Test] + public function allow_list_does_not_affect_modifier_usage_in_trusted_templates() + { + GlobalRuntimeState::$allowedContentModifierPaths = []; + + $result = $this->renderString('{{ title | lower }}', [ + 'title' => 'HELLO', + ], true, true); + + $this->assertSame('hello', $result); + } + + #[Test] + public function allowed_tag_pattern_can_be_used_in_user_content() + { + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['runtime_test_tag:*']; + + $value = $this->makeAntlersTextValue('{{ runtime_test_tag }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + ], true, true); + + $this->assertSame('tag-ok', $result); + } + + #[Test] + public function disallowed_tag_pattern_is_blocked_in_user_content() + { + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['other_tag']; + + $value = $this->makeAntlersTextValue('{{ runtime_test_tag }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + ], true, true); + + $this->assertSame('', $result); + } + + #[Test] + public function empty_tag_allow_list_blocks_all_tags_in_user_content() + { + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = []; + + $value = $this->makeAntlersTextValue('{{ runtime_test_tag }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + ], true, true); + + $this->assertSame('', $result); + } + + #[Test] + public function tag_block_list_overrides_tag_allow_list_in_user_content() + { + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['runtime_test_tag:*']; + GlobalRuntimeState::$bannedContentTagPaths = ['runtime_test_tag:*']; + + $value = $this->makeAntlersTextValue('{{ runtime_test_tag }}'); + $result = $this->renderString('{{ text_field }}', [ + 'text_field' => $value, + ], true, true); + + $this->assertSame('', $result); + } + + #[Test] + public function allow_list_does_not_affect_tag_usage_in_trusted_templates() + { + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = []; + GlobalRuntimeState::$bannedTagPaths = ['another_tag']; + + $result = $this->renderString('{{ runtime_test_tag }}', [], true, true); + $this->assertSame('tag-ok', $result); + } + + private function makeAntlersTextValue(string $template): Value + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + + return new Value($template, 'text_field', $textFieldtype); + } + + private function registerRuntimeTestTag(): void + { + (new class extends Tags + { + public static $handle = 'runtime_test_tag'; + + public function index() + { + return 'tag-ok'; + } + })::register(); + } +} diff --git a/tests/Antlers/Runtime/MethodCallTest.php b/tests/Antlers/Runtime/MethodCallTest.php index 4f84b977f95..d7f4782f132 100644 --- a/tests/Antlers/Runtime/MethodCallTest.php +++ b/tests/Antlers/Runtime/MethodCallTest.php @@ -33,10 +33,10 @@ public function test_methods_can_be_called() $this->assertSame('Value: hello', $this->renderString('{{ object:method("hello"):methodTwo() }}', [ 'object' => $object, - ])); + ], false, true)); $this->assertSame('String: hello', $this->renderString('{{ object:method("hello") }}', [ 'object' => $object, - ])); + ], false, true)); } public function test_chained_methods_colon_syntax() @@ -45,7 +45,7 @@ public function test_chained_methods_colon_syntax() $this->assertSame('Value: hello', $this->renderString('{{ object:method("hello"):methodTwo() }}', [ 'object' => $object, - ])); + ], false, true)); } public function test_chained_methods_dot_syntax() @@ -54,7 +54,7 @@ public function test_chained_methods_dot_syntax() $this->assertSame('Value: hello', $this->renderString('{{ object.method("hello").methodTwo() }}', [ 'object' => $object, - ])); + ], false, true)); } public function test_chained_methods_mixed_syntax() @@ -63,7 +63,7 @@ public function test_chained_methods_mixed_syntax() $this->assertSame('Value: hello', $this->renderString('{{ object:method("hello").methodTwo() }}', [ 'object' => $object, - ])); + ], false, true)); } public function test_method_calls_can_be_used_within_conditions_without_explicit_logic_groups() @@ -78,7 +78,7 @@ public function test_method_calls_can_be_used_within_conditions_without_explicit {{ if title && title:length() < 15 }}Yes{{ else }}No{{ endif }} EOT; - $this->assertSame('Yes', $this->renderString($template, $data)); + $this->assertSame('Yes', $this->renderString($template, $data, false, true)); } public function test_method_calls_can_be_used_within_conditions_without_explicit_logic_groups_dot_syntax() @@ -93,7 +93,7 @@ public function test_method_calls_can_be_used_within_conditions_without_explicit {{ if title && title.length() < 15 }}Yes{{ else }}No{{ endif }} EOT; - $this->assertSame('Yes', $this->renderString($template, $data)); + $this->assertSame('Yes', $this->renderString($template, $data, false, true)); } public function test_method_calls_can_be_used_within_conditions_without_explicit_logic_groups_arrow_syntax() @@ -108,7 +108,7 @@ public function test_method_calls_can_be_used_within_conditions_without_explicit {{ if title && title->length() < 15 }}Yes{{ else }}No{{ endif }} EOT; - $this->assertSame('Yes', $this->renderString($template, $data)); + $this->assertSame('Yes', $this->renderString($template, $data, false, true)); } public function test_method_calls_can_be_used_within_conditions_without_explicit_logic_groups_arrow_syntax_with_strict_var() @@ -123,7 +123,7 @@ public function test_method_calls_can_be_used_within_conditions_without_explicit {{ if title && $title->length() < 15 }}Yes{{ else }}No{{ endif }} EOT; - $this->assertSame('Yes', $this->renderString($template, $data)); + $this->assertSame('Yes', $this->renderString($template, $data, false, true)); } public function test_method_calls_can_have_modifiers_applied() @@ -234,7 +234,7 @@ public function test_method_calls_can_have_modifiers_applied() 2012-11-05 00:00:00 EOT; - $this->assertSame($expected, trim($this->renderString($template, $data, true))); + $this->assertSame($expected, trim($this->renderString($template, $data, true, true))); } public function test_method_calls_not_get_called_more_than_declared() @@ -245,7 +245,7 @@ public function test_method_calls_not_get_called_more_than_declared() {{ counter:increment():increment():increment() }} EOT; - $this->assertSame('Count: 3', $this->renderString($template, ['counter' => $counter])); + $this->assertSame('Count: 3', $this->renderString($template, ['counter' => $counter], false, true)); } public function test_dangling_chained_method_calls() @@ -257,7 +257,7 @@ public function test_dangling_chained_method_calls() toAtomString() }} ANTLERS; - $result = $this->renderString($template, ['datetime' => new TestDateTime]); + $result = $this->renderString($template, ['datetime' => new TestDateTime], false, true); $this->assertSame('2001-10-22T00:00:00+00:00', $result); } @@ -281,7 +281,7 @@ public function test_method_calls_blocked_in_user_content() $result = $this->renderString('{{ text_field }}', [ 'text_field' => $value, 'object' => $object, - ]); + ], false, true); $this->assertSame('', $result); } @@ -303,7 +303,7 @@ public function test_method_calls_allowed_in_user_content_when_configured() $result = $this->renderString('{{ text_field }}', [ 'text_field' => $value, 'object' => $object, - ]); + ], false, true); $this->assertSame('String: hello', $result); @@ -329,7 +329,7 @@ public function test_method_calls_in_user_content_throw_when_configured() $this->renderString('{{ text_field }}', [ 'text_field' => $value, 'object' => $object, - ]); + ], false, true); GlobalRuntimeState::$throwErrorOnAccessViolation = false; } @@ -340,7 +340,7 @@ public function test_method_calls_still_work_in_templates() $this->assertSame('String: hello', $this->renderString('{{ object:method("hello") }}', [ 'object' => $object, - ])); + ], false, true)); } public function test_nested_value_does_not_reset_user_data_flag() @@ -372,7 +372,7 @@ public function test_nested_value_does_not_reset_user_data_flag() 'outer_field' => $outerValue, 'nested_field' => $nestedValue, 'object' => $object, - ]); + ], false, true); $this->assertSame('Hello', $result); } diff --git a/tests/Antlers/Runtime/PhpDisabledTest.php b/tests/Antlers/Runtime/PhpDisabledTest.php new file mode 100644 index 00000000000..2e445e72d64 --- /dev/null +++ b/tests/Antlers/Runtime/PhpDisabledTest.php @@ -0,0 +1,139 @@ +assertSame('Before After', $result); + } + + public function test_it_ignores_inline_echo_blocks_when_disabled() + { + $result = (string) Antlers::parse('Before {{$ "hello" $}} After', []); + + $this->assertSame('Before After', $result); + } + + public function test_php_disabled_is_the_default() + { + $result = (string) Antlers::parse('Before {{? echo "hello"; ?}} After', []); + + $this->assertSame('Before After', $result); + } + + public function test_inline_php_tags_disabled_is_the_default() + { + $result = (string) Antlers::parse('Before After', []); + + $this->assertSame('Before <?php echo "hello"; ?> After', $result); + } + + public function test_it_allows_inline_echo_blocks_when_enabled() + { + $result = (string) Antlers::parse('Before {{$ "hello" $}} After', [], true); + + $this->assertSame('Before hello After', $result); + } + + public function test_it_allow_inline_php_blocks_when_enabled() + { + $result = (string) Antlers::parse('Before {{? echo "hello"; ?}} After', [], true); + + $this->assertSame('Before hello After', $result); + } + + public function test_method_calls_are_not_evaluated_when_php_is_disabled() + { + $helper = new class() + { + public $wasCalled = false; + + public function mutate() + { + $this->wasCalled = true; + + return 'changed'; + } + }; + + $result = (string) Antlers::parse('{{ helper:mutate() }}', [ + 'helper' => $helper, + ], false); + + $this->assertSame('', $result); + $this->assertFalse($helper->wasCalled); + } + + public function test_method_calls_are_evaluated_when_php_is_enabled() + { + $helper = new class() + { + public $wasCalled = false; + + public function mutate() + { + $this->wasCalled = true; + + return 'changed'; + } + }; + + $result = (string) Antlers::parse('{{ helper:mutate() }}', [ + 'helper' => $helper, + ], true); + + $this->assertSame('changed', $result); + $this->assertTrue($helper->wasCalled); + } + + public function test_strict_variable_method_calls_are_not_evaluated_when_php_is_disabled() + { + $helper = new class() + { + public $wasCalled = false; + + public function mutate() + { + $this->wasCalled = true; + + return 'changed'; + } + }; + + $result = (string) Antlers::parse('{{ $helper->mutate() }}', [ + 'helper' => $helper, + ], false); + + $this->assertSame('', $result); + $this->assertFalse($helper->wasCalled); + } + + public function test_strict_variable_method_calls_are_evaluated_when_php_is_enabled() + { + $helper = new class() + { + public $wasCalled = false; + + public function mutate() + { + $this->wasCalled = true; + + return 'changed'; + } + }; + + $result = (string) Antlers::parse('{{ $helper->mutate() }}', [ + 'helper' => $helper, + ], true); + + $this->assertSame('changed', $result); + $this->assertTrue($helper->wasCalled); + } +} diff --git a/tests/Antlers/Runtime/PhpEnabledTest.php b/tests/Antlers/Runtime/PhpEnabledTest.php index cd1122defbb..63f02d46296 100644 --- a/tests/Antlers/Runtime/PhpEnabledTest.php +++ b/tests/Antlers/Runtime/PhpEnabledTest.php @@ -3,7 +3,6 @@ namespace Tests\Antlers\Runtime; use Illuminate\Support\Facades\Log; -use PHPUnit\Framework\Attributes\Test; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; @@ -27,7 +26,7 @@ public function test_php_has_access_to_scope_data() $this->assertEquals( 'Hello wildernessWILDERNESS!', - (string) $this->parser($data)->allowPhp()->parse('Hello {{ string }}', $data) + (string) $this->parser($data, false, true)->allowPhp()->parse('Hello {{ string }}', $data) ); } @@ -52,7 +51,7 @@ public function test_php_can_be_used_to_output_evaluated_antlers() $data = ['title' => 'Hello, there!']; $expected = StringUtilities::normalizeLineEndings($expected); - $result = StringUtilities::normalizeLineEndings((string) $this->parser($data)->allowPhp()->parse($template, $data)); + $result = StringUtilities::normalizeLineEndings((string) $this->parser($data, false, true)->allowPhp()->parse($template, $data)); $this->assertSame($expected, $result); } @@ -89,7 +88,7 @@ public function test_php_variable_access_inside_loops() EOT; $expected = StringUtilities::normalizeLineEndings($expected); - $result = StringUtilities::normalizeLineEndings((string) $this->parser($data)->allowPhp()->parse($template, $data)); + $result = StringUtilities::normalizeLineEndings((string) $this->parser($data, false, true)->allowPhp()->parse($template, $data)); $this->assertSame($expected, $result); } @@ -140,7 +139,7 @@ public function config(?string $key = null, $fallback = null) $expected = StringUtilities::normalizeLineEndings($expected); $results = StringUtilities::normalizeLineEndings( - (string) $this->parser($data)->setRuntimeConfiguration($config)->allowPhp()->parse($template, $data) + (string) $this->parser($data, false, true)->setRuntimeConfiguration($config)->allowPhp()->parse($template, $data) ); $this->assertSame($expected, $results); @@ -188,7 +187,7 @@ public function config(?string $key = null, $fallback = null) $expected = StringUtilities::normalizeLineEndings($expected); $results = StringUtilities::normalizeLineEndings( - (string) $this->parser($data)->setRuntimeConfiguration($config)->allowPhp()->parse($template, $data) + (string) $this->parser($data, false, true)->setRuntimeConfiguration($config)->allowPhp()->parse($template, $data) ); $this->assertSame($expected, $results); @@ -317,7 +316,7 @@ public function test_implicit_antlers_php_node() } $results = StringUtilities::normalizeLineEndings( - (string) $this->parser($data)->allowPhp()->parse($template, $data) + (string) $this->parser($data, false, true)->allowPhp()->parse($template, $data) ); $expected = StringUtilities::normalizeLineEndings($expected); @@ -446,7 +445,7 @@ public function test_antlers_php_node_can_return_assignments() } $results = StringUtilities::normalizeLineEndings( - (string) $this->parser($data)->allowPhp()->parse($template, $data) + (string) $this->parser($data, false, true)->allowPhp()->parse($template, $data) ); $expected = StringUtilities::normalizeLineEndings($expected); @@ -460,7 +459,7 @@ public function test_antlers_php_node_does_not_remove_literal() {{? $var_1 = 'blog'; $var_2 = 'news'; ?}}ABC{{ var_2 }} EOT; - $this->assertSame('ABCnews', $this->renderString($template)); + $this->assertSame('ABCnews', $this->renderString($template, [], false, true)); } public function test_antlers_php_echo_node() @@ -470,7 +469,7 @@ public function test_antlers_php_echo_node()
Literal Content. {{$ $var $}}
Literal Content. hi!
Literal Content. hi!