From 9f2a5f7f1942c1cf75103d6e925399a6f8f7d5de Mon Sep 17 00:00:00 2001 From: John Koster Date: Wed, 25 Feb 2026 23:46:13 -0600 Subject: [PATCH 01/22] Disables PHP by default when calling Antlers::parse --- src/Facades/Antlers.php | 2 +- src/Facades/Endpoint/Parse.php | 2 +- src/Providers/ViewServiceProvider.php | 1 + src/View/Antlers/Antlers.php | 12 ++++- .../Language/Runtime/GlobalRuntimeState.php | 8 +++ .../Language/Runtime/NodeProcessor.php | 14 ++++- .../Language/Runtime/RuntimeConfiguration.php | 7 +++ .../Language/Runtime/RuntimeParser.php | 1 + tests/Antlers/Runtime/PhpDisabledTest.php | 51 +++++++++++++++++++ 9 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/Antlers/Runtime/PhpDisabledTest.php diff --git a/src/Facades/Antlers.php b/src/Facades/Antlers.php index e29d69889bd..eeeed41ced1 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -9,7 +9,7 @@ /** * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) - * @method static AntlersString parse(string $str, array $variables = []) + * @method static AntlersString parse(string $str, array $variables = [], bool $php = false) * @method static AntlersString parseUserContent(string $str, array $variables = []) * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = []) * @method static array identifiers(string $content) diff --git a/src/Facades/Endpoint/Parse.php b/src/Facades/Endpoint/Parse.php index 6f447d96541..ebd59ffab63 100644 --- a/src/Facades/Endpoint/Parse.php +++ b/src/Facades/Endpoint/Parse.php @@ -22,7 +22,7 @@ class Parse */ public function template($str, $variables = [], $context = [], $php = false) { - return Antlers::parse($str, $variables, $context, $php); + return Antlers::parse($str, array_merge($variables, $context), $php); } /** diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index b16050a7f36..ed2ab54bbba 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -102,6 +102,7 @@ private function registerAntlers() $runtimeConfig->guardedContentVariablePatterns = config('statamic.antlers.guardedContentVariables', []); $runtimeConfig->guardedContentTagPatterns = config('statamic.antlers.guardedContentTags', []); $runtimeConfig->guardedContentModifiers = config('statamic.antlers.guardedContentModifiers', []); + $runtimeConfig->isPhpEnabled = config('statamic.antlers.allowPhp', true); $runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false); $runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false); diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index b1dc611cfbc..0a6829ff3c5 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -27,9 +27,17 @@ public function usingParser(Parser $parser, Closure $callback) return $contents; } - public function parse($str, $variables = []) + public function parse($str, $variables = [], $php = false) { - return $this->parser()->parse($str, $variables); + $parser = $this->parser(); + $previousState = GlobalRuntimeState::$isPhpEnabled; + GlobalRuntimeState::$isPhpEnabled = $php; + + try { + return $parser->parse($str, $variables); + } finally { + GlobalRuntimeState::$isPhpEnabled = $previousState; + } } public function parseUserContent($str, $variables = []) diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index a0d2b481508..c8f2101ae09 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -184,6 +184,13 @@ public static function mergeTagRuntimeAssignments($assignments) */ public static $allowPhpInContent = false; + /** + * Controls whether PHP execution is globally enabled. + * + * @var bool + */ + public static $isPhpEnabled = true; + /** * Controls if method invocations are evaluated in user content. * @@ -235,6 +242,7 @@ public static function resetGlobalState() self::$abandonedNodes = []; self::$isEvaluatingUserData = false; self::$isEvaluatingData = false; + self::$isPhpEnabled = true; self::$userContentEvalState = null; StackReplacementManager::clearStackState(); diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 4450d838d32..5fe44fc5466 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -1210,6 +1210,10 @@ public function reduce($processNodes) } if ($node instanceof PhpExecutionNode) { + if (! GlobalRuntimeState::$isPhpEnabled) { + continue; + } + if (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowPhpInContent) { if (GlobalRuntimeState::$throwErrorOnAccessViolation) { throw ErrorFactory::makeRuntimeError( @@ -2423,7 +2427,7 @@ public function reduce($processNodes) // one last time to make sure we didn't miss anything. $this->stopMeasuringTag(); - if ($this->allowPhp) { + if ($this->allowPhp && GlobalRuntimeState::$isPhpEnabled) { $buffer = $this->evaluatePhp($buffer); } @@ -2438,6 +2442,10 @@ public function reduce($processNodes) */ protected function evaluatePhp($buffer) { + if (! GlobalRuntimeState::$isPhpEnabled) { + return is_array($buffer) ? $buffer : StringUtilities::sanitizePhp($buffer); + } + if (is_array($buffer) || $this->isLoopable($buffer)) { return $buffer; } @@ -2464,6 +2472,10 @@ protected function evaluatePhp($buffer) protected function evaluateAntlersPhpNode(PhpExecutionNode $node) { + if (! GlobalRuntimeState::$isPhpEnabled) { + return ''; + } + if (! GlobalRuntimeState::$allowPhpInContent && GlobalRuntimeState::$isEvaluatingUserData) { return StringUtilities::sanitizePhp($node->content); } diff --git a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php index 548be3452f0..8cfefc5670d 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php +++ b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php @@ -98,6 +98,13 @@ class RuntimeConfiguration */ public $guardedContentModifiers = []; + /** + * Controls whether PHP execution is globally enabled. + * + * @var bool + */ + public $isPhpEnabled = true; + /** * 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..53934b425c0 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -133,6 +133,7 @@ public function __construct(DocumentParser $documentParser, NodeProcessor $nodeP */ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) { + GlobalRuntimeState::$isPhpEnabled = $configuration->isPhpEnabled; GlobalRuntimeState::$allowPhpInContent = $configuration->allowPhpInUserContent; GlobalRuntimeState::$allowMethodsInContent = $configuration->allowMethodsInUserContent; GlobalRuntimeState::$throwErrorOnAccessViolation = $configuration->throwErrorOnAccessViolation; diff --git a/tests/Antlers/Runtime/PhpDisabledTest.php b/tests/Antlers/Runtime/PhpDisabledTest.php new file mode 100644 index 00000000000..88a0bc4c1e1 --- /dev/null +++ b/tests/Antlers/Runtime/PhpDisabledTest.php @@ -0,0 +1,51 @@ +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); + } +} From a5b882eeae70554fb68c74bdcc9f7639321e7b61 Mon Sep 17 00:00:00 2001 From: John Koster Date: Wed, 25 Feb 2026 23:53:59 -0600 Subject: [PATCH 02/22] Update PhpDisabledTest.php --- tests/Antlers/Runtime/PhpDisabledTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Antlers/Runtime/PhpDisabledTest.php b/tests/Antlers/Runtime/PhpDisabledTest.php index 88a0bc4c1e1..3e19a4ef690 100644 --- a/tests/Antlers/Runtime/PhpDisabledTest.php +++ b/tests/Antlers/Runtime/PhpDisabledTest.php @@ -39,7 +39,7 @@ public function test_it_allows_inline_echo_blocks_when_enabled() { $result = (string) Antlers::parse('Before {{$ "hello" $}} After', [], true); - $this->assertSame('Before hello After', $result); + $this->assertSame('Before hello After', $result); } public function test_it_allow_inline_php_blocks_when_enabled() From f8564d6caf07bc9d66eb7f34923ef4d2608d3c96 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 11:32:33 -0500 Subject: [PATCH 03/22] prevent method calls --- .../Language/Runtime/Sandbox/Environment.php | 11 ++- tests/Antlers/ParseUserContentTest.php | 17 ++++ tests/Antlers/Runtime/PhpDisabledTest.php | 88 +++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index 4c7f855a8e1..3cac600a0ca 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -891,16 +891,21 @@ public function process($nodes) continue; } elseif ($currentNode instanceof MethodInvocationNode) { - if (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent) { + $isMethodCallDisabled = ! GlobalRuntimeState::$isPhpEnabled + || (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent); + + if ($isMethodCallDisabled) { array_pop($stack); - if (GlobalRuntimeState::$throwErrorOnAccessViolation) { + if (GlobalRuntimeState::$isEvaluatingUserData + && ! GlobalRuntimeState::$allowMethodsInContent + && GlobalRuntimeState::$throwErrorOnAccessViolation) { throw ErrorFactory::makeRuntimeError( AntlersErrorCodes::RUNTIME_METHOD_CALL_USER_CONTENT, $currentNode, 'Method invocation in user content.' ); - } else { + } elseif (GlobalRuntimeState::$isEvaluatingUserData) { Log::warning('Method call evaluated in user content.', [ 'file' => GlobalRuntimeState::$currentExecutionFile, 'trace' => GlobalRuntimeState::$templateFileStack, diff --git a/tests/Antlers/ParseUserContentTest.php b/tests/Antlers/ParseUserContentTest.php index 5177ee45152..e2a6b056bb9 100644 --- a/tests/Antlers/ParseUserContentTest.php +++ b/tests/Antlers/ParseUserContentTest.php @@ -57,6 +57,23 @@ public function it_blocks_method_calls_in_user_content_mode() $this->assertSame('', $result); } + #[Test] + public function it_blocks_method_calls_when_php_is_disabled_even_if_methods_are_enabled() + { + GlobalRuntimeState::$allowMethodsInContent = true; + GlobalRuntimeState::$isPhpEnabled = false; + + 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() { diff --git a/tests/Antlers/Runtime/PhpDisabledTest.php b/tests/Antlers/Runtime/PhpDisabledTest.php index 3e19a4ef690..2e445e72d64 100644 --- a/tests/Antlers/Runtime/PhpDisabledTest.php +++ b/tests/Antlers/Runtime/PhpDisabledTest.php @@ -48,4 +48,92 @@ public function test_it_allow_inline_php_blocks_when_enabled() $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); + } } From a5aba7d13c6fee1accfd41abf287eb7e21829dc4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 11:51:56 -0500 Subject: [PATCH 04/22] remove parseUserContent --- src/Entries/Entry.php | 4 +- src/Facades/Antlers.php | 1 - src/Forms/Email.php | 2 +- src/View/Antlers/Antlers.php | 12 --- tests/Antlers/ParseUserContentTest.php | 108 ------------------------- 5 files changed, 3 insertions(+), 124 deletions(-) delete mode 100644 tests/Antlers/ParseUserContentTest.php 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 eeeed41ced1..ea60d8690f3 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -10,7 +10,6 @@ * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) * @method static AntlersString parse(string $str, array $variables = [], bool $php = false) - * @method static AntlersString parseUserContent(string $str, array $variables = []) * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = []) * @method static array identifiers(string $content) * 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/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index 0a6829ff3c5..73bdb351543 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -40,18 +40,6 @@ public function parse($str, $variables = [], $php = false) } } - public function parseUserContent($str, $variables = []) - { - $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; - GlobalRuntimeState::$isEvaluatingUserData = true; - - try { - return $this->parser()->parse($str, $variables); - } finally { - GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; - } - } - /** * Iterate over an array and parse the string/template for each. * diff --git a/tests/Antlers/ParseUserContentTest.php b/tests/Antlers/ParseUserContentTest.php deleted file mode 100644 index e2a6b056bb9..00000000000 --- a/tests/Antlers/ParseUserContentTest.php +++ /dev/null @@ -1,108 +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_blocks_method_calls_when_php_is_disabled_even_if_methods_are_enabled() - { - GlobalRuntimeState::$allowMethodsInContent = true; - GlobalRuntimeState::$isPhpEnabled = false; - - 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); - } -} From b30923bcc7f988d742e5fac17d0c0d93d8c6c253 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:21:14 -0500 Subject: [PATCH 05/22] Default Antlers runtime to user-content safety mode. Remove the redundant isPhpEnabled global and make isEvaluatingUserData the single trust gate, while forcing parseView to render in trusted mode and preserving state boundaries in site config parsing. Made-with: Cursor --- src/Providers/ViewServiceProvider.php | 1 - src/Sites/Site.php | 4 +- .../Language/Runtime/GlobalRuntimeState.php | 12 +---- .../Language/Runtime/NodeProcessor.php | 14 +---- .../Language/Runtime/RuntimeConfiguration.php | 7 --- .../Language/Runtime/RuntimeParser.php | 54 ++++++++++--------- .../Language/Runtime/Sandbox/Environment.php | 3 +- 7 files changed, 34 insertions(+), 61 deletions(-) diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index ed2ab54bbba..b16050a7f36 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -102,7 +102,6 @@ private function registerAntlers() $runtimeConfig->guardedContentVariablePatterns = config('statamic.antlers.guardedContentVariables', []); $runtimeConfig->guardedContentTagPatterns = config('statamic.antlers.guardedContentTags', []); $runtimeConfig->guardedContentModifiers = config('statamic.antlers.guardedContentModifiers', []); - $runtimeConfig->isPhpEnabled = config('statamic.antlers.allowPhp', true); $runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false); $runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false); diff --git a/src/Sites/Site.php b/src/Sites/Site.php index bc46e744b76..fa56fc09975 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -131,13 +131,13 @@ protected function resolveAntlersValue($value) ->all(); } - $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = true; try { return (string) app(RuntimeParser::class)->parse($value, ['config' => Cascade::config()]); } finally { - GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; } } diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index c8f2101ae09..92a3454c289 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; @@ -184,13 +184,6 @@ public static function mergeTagRuntimeAssignments($assignments) */ public static $allowPhpInContent = false; - /** - * Controls whether PHP execution is globally enabled. - * - * @var bool - */ - public static $isPhpEnabled = true; - /** * Controls if method invocations are evaluated in user content. * @@ -240,9 +233,8 @@ public static function resetGlobalState() self::$yieldCount = 0; self::$yieldStacks = []; self::$abandonedNodes = []; - self::$isEvaluatingUserData = false; + self::$isEvaluatingUserData = true; self::$isEvaluatingData = false; - self::$isPhpEnabled = true; self::$userContentEvalState = null; StackReplacementManager::clearStackState(); diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 5fe44fc5466..4450d838d32 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -1210,10 +1210,6 @@ public function reduce($processNodes) } if ($node instanceof PhpExecutionNode) { - if (! GlobalRuntimeState::$isPhpEnabled) { - continue; - } - if (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowPhpInContent) { if (GlobalRuntimeState::$throwErrorOnAccessViolation) { throw ErrorFactory::makeRuntimeError( @@ -2427,7 +2423,7 @@ public function reduce($processNodes) // one last time to make sure we didn't miss anything. $this->stopMeasuringTag(); - if ($this->allowPhp && GlobalRuntimeState::$isPhpEnabled) { + if ($this->allowPhp) { $buffer = $this->evaluatePhp($buffer); } @@ -2442,10 +2438,6 @@ public function reduce($processNodes) */ protected function evaluatePhp($buffer) { - if (! GlobalRuntimeState::$isPhpEnabled) { - return is_array($buffer) ? $buffer : StringUtilities::sanitizePhp($buffer); - } - if (is_array($buffer) || $this->isLoopable($buffer)) { return $buffer; } @@ -2472,10 +2464,6 @@ protected function evaluatePhp($buffer) protected function evaluateAntlersPhpNode(PhpExecutionNode $node) { - if (! GlobalRuntimeState::$isPhpEnabled) { - return ''; - } - if (! GlobalRuntimeState::$allowPhpInContent && GlobalRuntimeState::$isEvaluatingUserData) { return StringUtilities::sanitizePhp($node->content); } diff --git a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php index 8cfefc5670d..548be3452f0 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php +++ b/src/View/Antlers/Language/Runtime/RuntimeConfiguration.php @@ -98,13 +98,6 @@ class RuntimeConfiguration */ public $guardedContentModifiers = []; - /** - * Controls whether PHP execution is globally enabled. - * - * @var bool - */ - public $isPhpEnabled = true; - /** * 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 53934b425c0..b5339f06bc3 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -133,7 +133,6 @@ public function __construct(DocumentParser $documentParser, NodeProcessor $nodeP */ public function setRuntimeConfiguration(RuntimeConfiguration $configuration) { - GlobalRuntimeState::$isPhpEnabled = $configuration->isPhpEnabled; GlobalRuntimeState::$allowPhpInContent = $configuration->allowPhpInUserContent; GlobalRuntimeState::$allowMethodsInContent = $configuration->allowMethodsInUserContent; GlobalRuntimeState::$throwErrorOnAccessViolation = $configuration->throwErrorOnAccessViolation; @@ -689,10 +688,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); } @@ -759,33 +759,35 @@ public function cascade($cascade) public function parseView($view, $text, $data = []) { - $existingView = $this->view; - $this->view = $view; - GlobalRuntimeState::$templateFileStack[] = [$view, null]; + $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = false; - if (count(GlobalRuntimeState::$templateFileStack) > 1) { - GlobalRuntimeState::$templateFileStack[count(GlobalRuntimeState::$templateFileStack) - 2][1] = GlobalRuntimeState::$lastNode; - } - - GlobalRuntimeState::$currentExecutionFile = $this->view; - - if (GlobalDebugManager::$isConnected) { - GlobalDebugManager::registerPathLocator($this->view); - } - - $data = array_merge($data, [ - 'view' => $this->cascade->getViewData($view), - ]); + $existingView = $this->view; + try { + $this->view = $view; + GlobalRuntimeState::$templateFileStack[] = [$view, null]; - $parsed = $this->renderText($text, $data); + if (count(GlobalRuntimeState::$templateFileStack) > 1) { + GlobalRuntimeState::$templateFileStack[count(GlobalRuntimeState::$templateFileStack) - 2][1] = GlobalRuntimeState::$lastNode; + } - $this->view = $existingView; + GlobalRuntimeState::$currentExecutionFile = $this->view; - array_pop(GlobalRuntimeState::$templateFileStack); + if (GlobalDebugManager::$isConnected) { + GlobalDebugManager::registerPathLocator($this->view); + } - GlobalRuntimeState::$currentExecutionFile = $this->view; + $data = array_merge($data, [ + 'view' => $this->cascade->getViewData($view), + ]); - return $parsed; + return $this->renderText($text, $data); + } finally { + $this->view = $existingView; + array_pop(GlobalRuntimeState::$templateFileStack); + GlobalRuntimeState::$currentExecutionFile = $this->view; + GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; + } } public function injectNoparse($text) diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index 3cac600a0ca..42c1b665fd7 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -891,8 +891,7 @@ public function process($nodes) continue; } elseif ($currentNode instanceof MethodInvocationNode) { - $isMethodCallDisabled = ! GlobalRuntimeState::$isPhpEnabled - || (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent); + $isMethodCallDisabled = GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent; if ($isMethodCallDisabled) { array_pop($stack); From 632b923d962742aaee5ea9810da2f18da9df8c26 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:21:25 -0500 Subject: [PATCH 06/22] Rename Antlers parse trust flag and propagate trusted contexts. Use a trusted parameter instead of php, carry it through parseLoop and endpoint facades, and ensure tag-rendered templates execute in trusted mode to preserve view-time behavior. Made-with: Cursor --- src/Facades/Antlers.php | 4 +-- src/Facades/Endpoint/Parse.php | 12 +++---- src/Facades/Parse.php | 4 +-- src/Tags/Tags.php | 4 +-- src/View/Antlers/Antlers.php | 13 +++---- src/View/Antlers/AntlersLoop.php | 59 +++++++++++++++++++------------- 6 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/Facades/Antlers.php b/src/Facades/Antlers.php index ea60d8690f3..c6b285ce9c0 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -9,8 +9,8 @@ /** * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) - * @method static AntlersString parse(string $str, array $variables = [], bool $php = false) - * @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 ebd59ffab63..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, array_merge($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/Tags/Tags.php b/src/Tags/Tags.php index b001cf348e3..c8028f4d872 100644 --- a/src/Tags/Tags.php +++ b/src/Tags/Tags.php @@ -208,7 +208,7 @@ public function parse($data = []) return Antlers::usingParser($this->parser, function ($antlers) use ($data) { return $antlers - ->parse($this->content, array_merge($this->context->all(), $data)) + ->parse($this->content, array_merge($this->context->all(), $data), true) ->withoutExtractions(); }); } @@ -245,7 +245,7 @@ public function parseLoop($data, $supplement = true) return Antlers::usingParser($this->parser, function ($antlers) use ($data, $supplement) { return $antlers - ->parseLoop($this->content, $data, $supplement, $this->context->all()) + ->parseLoop($this->content, $data, $supplement, $this->context->all(), true) ->withoutExtractions(); }); } diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index 73bdb351543..606ac973cb0 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -27,16 +27,16 @@ public function usingParser(Parser $parser, Closure $callback) return $contents; } - public function parse($str, $variables = [], $php = false) + public function parse($str, $variables = [], $trusted = false) { $parser = $this->parser(); - $previousState = GlobalRuntimeState::$isPhpEnabled; - GlobalRuntimeState::$isPhpEnabled = $php; + $previousState = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = ! $trusted; try { return $parser->parse($str, $variables); } finally { - GlobalRuntimeState::$isPhpEnabled = $previousState; + GlobalRuntimeState::$isEvaluatingUserData = $previousState; } } @@ -47,11 +47,12 @@ public function parse($str, $variables = [], $php = false) * @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..0d756daf244 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,43 +11,52 @@ 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; + $total = count($this->variables); $i = 0; - $contents = collect($this->variables)->reduce(function ($carry, $item) use (&$i, $total) { - if ($this->supplement) { - $item = array_merge($item, [ - 'index' => $i, - 'count' => $i + 1, - 'total_results' => $total, - 'first' => ($i === 0), - 'last' => ($i === $total - 1), - ]); - } - - $i++; - - $parsed = $this->parser - ->parse($this->string, array_merge($this->context, $item)) - ->withoutExtractions(); - - return $carry.$parsed; - }, ''); - - $string = new AntlersString($contents, $this->parser); - - return (string) ($this->injectExtractions ? $string : $string->withoutExtractions()); + try { + $contents = collect($this->variables)->reduce(function ($carry, $item) use (&$i, $total) { + if ($this->supplement) { + $item = array_merge($item, [ + 'index' => $i, + 'count' => $i + 1, + 'total_results' => $total, + 'first' => ($i === 0), + 'last' => ($i === $total - 1), + ]); + } + + $i++; + + $parsed = $this->parser + ->parse($this->string, array_merge($this->context, $item)) + ->withoutExtractions(); + + return $carry.$parsed; + }, ''); + + $string = new AntlersString($contents, $this->parser); + + return (string) ($this->injectExtractions ? $string : $string->withoutExtractions()); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; + } } } From d849c8ee9d9e6d8c473c73e30138397efa38c5eb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:21:33 -0500 Subject: [PATCH 07/22] Align Antlers runtime tests with explicit trust semantics. Default parser test helpers to trusted mode for legacy runtime expectations and mark method/PHP execution assertions as trusted where they verify developer-template behavior. Made-with: Cursor --- tests/Antlers/ParserTestCase.php | 9 ++++--- tests/Antlers/Runtime/MethodCallTest.php | 34 ++++++++++++------------ tests/Antlers/Runtime/PhpEnabledTest.php | 23 ++++++++-------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/tests/Antlers/ParserTestCase.php b/tests/Antlers/ParserTestCase.php index a51e64bed02..bb53e719549 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(); 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/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 $}}

EOT; - $this->assertSame('

Literal Content. hi!

', trim($this->renderString($template))); + $this->assertSame('

Literal Content. hi!

', trim($this->renderString($template, [], false, true))); } public function test_php_node_assignments_within_loops() @@ -511,7 +510,7 @@ public function test_php_node_assignments_within_loops() <1> EOT; - $this->assertSame($expected, trim($this->renderString($template, $data))); + $this->assertSame($expected, trim($this->renderString($template, $data, false, true))); } public function test_assignments_from_php_nodes() @@ -533,7 +532,7 @@ public function test_assignments_from_php_nodes() EOT; - $result = $this->renderString($template, [], true); + $result = $this->renderString($template, [], true, true); $this->assertStringContainsString('', $result); $this->assertStringContainsString('', $result); } From 61cf4e52b77038fd2b7a5de0f7d77ad2046fda7b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:37:42 -0500 Subject: [PATCH 08/22] wip --- src/View/Antlers/AntlersLoop.php | 49 ++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/View/Antlers/AntlersLoop.php b/src/View/Antlers/AntlersLoop.php index 0d756daf244..4af1a1b4bc9 100644 --- a/src/View/Antlers/AntlersLoop.php +++ b/src/View/Antlers/AntlersLoop.php @@ -28,35 +28,40 @@ 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; - try { - $contents = collect($this->variables)->reduce(function ($carry, $item) use (&$i, $total) { - if ($this->supplement) { - $item = array_merge($item, [ - 'index' => $i, - 'count' => $i + 1, - 'total_results' => $total, - 'first' => ($i === 0), - 'last' => ($i === $total - 1), - ]); - } + $contents = collect($this->variables)->reduce(function ($carry, $item) use (&$i, $total) { + if ($this->supplement) { + $item = array_merge($item, [ + 'index' => $i, + 'count' => $i + 1, + 'total_results' => $total, + 'first' => ($i === 0), + 'last' => ($i === $total - 1), + ]); + } - $i++; + $i++; - $parsed = $this->parser - ->parse($this->string, array_merge($this->context, $item)) - ->withoutExtractions(); + $parsed = $this->parser + ->parse($this->string, array_merge($this->context, $item)) + ->withoutExtractions(); - return $carry.$parsed; - }, ''); + return $carry.$parsed; + }, ''); - $string = new AntlersString($contents, $this->parser); + $string = new AntlersString($contents, $this->parser); - return (string) ($this->injectExtractions ? $string : $string->withoutExtractions()); - } finally { - GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; - } + return (string) ($this->injectExtractions ? $string : $string->withoutExtractions()); } } From 114e50cb207d4de0358efcc605386e6ebc8b097e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:39:56 -0500 Subject: [PATCH 09/22] wip --- .../Language/Runtime/RuntimeParser.php | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index b5339f06bc3..e1ec4c51609 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -764,24 +764,7 @@ public function parseView($view, $text, $data = []) $existingView = $this->view; try { - $this->view = $view; - GlobalRuntimeState::$templateFileStack[] = [$view, null]; - - if (count(GlobalRuntimeState::$templateFileStack) > 1) { - GlobalRuntimeState::$templateFileStack[count(GlobalRuntimeState::$templateFileStack) - 2][1] = GlobalRuntimeState::$lastNode; - } - - GlobalRuntimeState::$currentExecutionFile = $this->view; - - if (GlobalDebugManager::$isConnected) { - GlobalDebugManager::registerPathLocator($this->view); - } - - $data = array_merge($data, [ - 'view' => $this->cascade->getViewData($view), - ]); - - return $this->renderText($text, $data); + return $this->renderViewContent($view, $text, $data); } finally { $this->view = $existingView; array_pop(GlobalRuntimeState::$templateFileStack); @@ -790,6 +773,28 @@ public function parseView($view, $text, $data = []) } } + private function renderViewContent($view, $text, $data = []) + { + $this->view = $view; + GlobalRuntimeState::$templateFileStack[] = [$view, null]; + + if (count(GlobalRuntimeState::$templateFileStack) > 1) { + GlobalRuntimeState::$templateFileStack[count(GlobalRuntimeState::$templateFileStack) - 2][1] = GlobalRuntimeState::$lastNode; + } + + GlobalRuntimeState::$currentExecutionFile = $this->view; + + if (GlobalDebugManager::$isConnected) { + GlobalDebugManager::registerPathLocator($this->view); + } + + $data = array_merge($data, [ + 'view' => $this->cascade->getViewData($view), + ]); + + return $this->renderText($text, $data); + } + public function injectNoparse($text) { return $text; From d53bf0cc5e16679c1aa3c4d0b7c351cc217514cf Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 13:58:17 -0500 Subject: [PATCH 10/22] Propagate ambient trust mode in tag pair reparsing. Derive trusted from the current user-content evaluation state instead of hardcoding trusted mode so nested tag parsing preserves caller safety context. Made-with: Cursor --- src/Tags/Tags.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Tags/Tags.php b/src/Tags/Tags.php index c8028f4d872..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), true) + ->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(), true) + ->parseLoop($this->content, $data, $supplement, $this->context->all(), $trusted) ->withoutExtractions(); }); } From 03365ff6caf6f343d0d2902f1b36b6094f353a0e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 15:26:11 -0500 Subject: [PATCH 11/22] Block tags and modifiers (aside from a handful) unless in trusted parses --- src/Providers/ViewServiceProvider.php | 5 + .../Language/Runtime/GlobalRuntimeState.php | 14 ++ .../Language/Runtime/ModifierManager.php | 28 ++- .../Language/Runtime/NodeProcessor.php | 28 ++- .../Language/Runtime/RuntimeConfiguration.php | 14 ++ .../Language/Runtime/RuntimeParser.php | 2 + .../Antlers/Runtime/ContentAllowListTest.php | 180 ++++++++++++++++++ 7 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 tests/Antlers/Runtime/ContentAllowListTest.php diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index b16050a7f36..657e73b6209 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -102,6 +102,11 @@ 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', []); + $runtimeConfig->allowedContentModifiers = config('statamic.antlers.allowedContentModifiers', [ + 'upper', + 'lower', + ]); $runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false); $runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false); diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index 92a3454c289..e0ebc3b05ca 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -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. * 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 e1ec4c51609..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); diff --git a/tests/Antlers/Runtime/ContentAllowListTest.php b/tests/Antlers/Runtime/ContentAllowListTest.php new file mode 100644 index 00000000000..e29b2712576 --- /dev/null +++ b/tests/Antlers/Runtime/ContentAllowListTest.php @@ -0,0 +1,180 @@ +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_passes_user_content_guard() + { + GlobalRuntimeState::$isEvaluatingUserData = true; + GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; + + $this->assertTrue($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + } + + #[Test] + public function disallowed_tag_pattern_fails_user_content_guard() + { + GlobalRuntimeState::$isEvaluatingUserData = true; + GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; + + $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('form:create')); + } + + #[Test] + public function empty_tag_allow_list_blocks_all_tags_in_user_content_guard() + { + GlobalRuntimeState::$isEvaluatingUserData = true; + GlobalRuntimeState::$allowedContentTagPaths = []; + + $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + } + + #[Test] + public function tag_block_list_overrides_tag_allow_list_in_user_content_guard() + { + GlobalRuntimeState::$isEvaluatingUserData = true; + GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; + GlobalRuntimeState::$bannedContentTagPaths = ['collection:*']; + + $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + } + + #[Test] + public function allow_list_does_not_affect_tag_usage_in_trusted_templates() + { + GlobalRuntimeState::$isEvaluatingUserData = false; + GlobalRuntimeState::$allowedContentTagPaths = []; + GlobalRuntimeState::$bannedTagPaths = []; + + $this->assertTrue($this->makeNodeProcessor()->guardRuntimeTag('form:create')); + } + + 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 makeNodeProcessor(): NodeProcessor + { + return new NodeProcessor(new Loader(), new EnvironmentDetails()); + } +} From b0cac8e32efd9fc39913f693129970bc75a9aa87 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 15:33:43 -0500 Subject: [PATCH 12/22] test tags through the parser, not directly through node processor --- .../Antlers/Runtime/ContentAllowListTest.php | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/tests/Antlers/Runtime/ContentAllowListTest.php b/tests/Antlers/Runtime/ContentAllowListTest.php index e29b2712576..42f70c27ca5 100644 --- a/tests/Antlers/Runtime/ContentAllowListTest.php +++ b/tests/Antlers/Runtime/ContentAllowListTest.php @@ -6,11 +6,9 @@ use Statamic\Fields\Field; use Statamic\Fields\Value; use Statamic\Fieldtypes\Text; -use Statamic\Tags\Loader; +use Statamic\Tags\Tags; use Statamic\View\Antlers\Language\Exceptions\RuntimeException; -use Statamic\View\Antlers\Language\Runtime\EnvironmentDetails; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; -use Statamic\View\Antlers\Language\Runtime\NodeProcessor; use Tests\Antlers\ParserTestCase; class ContentAllowListTest extends ParserTestCase @@ -114,50 +112,71 @@ public function allow_list_does_not_affect_modifier_usage_in_trusted_templates() } #[Test] - public function allowed_tag_pattern_passes_user_content_guard() + public function allowed_tag_pattern_can_be_used_in_user_content() { - GlobalRuntimeState::$isEvaluatingUserData = true; - GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['runtime_test_tag:*']; - $this->assertTrue($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + $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_fails_user_content_guard() + public function disallowed_tag_pattern_is_blocked_in_user_content() { - GlobalRuntimeState::$isEvaluatingUserData = true; - GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['other_tag']; - $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('form:create')); + $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_guard() + public function empty_tag_allow_list_blocks_all_tags_in_user_content() { - GlobalRuntimeState::$isEvaluatingUserData = true; + $this->registerRuntimeTestTag(); GlobalRuntimeState::$allowedContentTagPaths = []; - $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + $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_guard() + public function tag_block_list_overrides_tag_allow_list_in_user_content() { - GlobalRuntimeState::$isEvaluatingUserData = true; - GlobalRuntimeState::$allowedContentTagPaths = ['collection:*']; - GlobalRuntimeState::$bannedContentTagPaths = ['collection:*']; + $this->registerRuntimeTestTag(); + GlobalRuntimeState::$allowedContentTagPaths = ['runtime_test_tag:*']; + GlobalRuntimeState::$bannedContentTagPaths = ['runtime_test_tag:*']; - $this->assertFalse($this->makeNodeProcessor()->guardRuntimeTag('collection:blog')); + $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() { - GlobalRuntimeState::$isEvaluatingUserData = false; + $this->registerRuntimeTestTag(); GlobalRuntimeState::$allowedContentTagPaths = []; - GlobalRuntimeState::$bannedTagPaths = []; + GlobalRuntimeState::$bannedTagPaths = ['another_tag']; - $this->assertTrue($this->makeNodeProcessor()->guardRuntimeTag('form:create')); + $result = $this->renderString('{{ runtime_test_tag }}', [], true, true); + $this->assertSame('tag-ok', $result); } private function makeAntlersTextValue(string $template): Value @@ -173,8 +192,16 @@ private function makeAntlersTextValue(string $template): Value return new Value($template, 'text_field', $textFieldtype); } - private function makeNodeProcessor(): NodeProcessor + private function registerRuntimeTestTag(): void { - return new NodeProcessor(new Loader(), new EnvironmentDetails()); + (new class extends Tags + { + public static $handle = 'runtime_test_tag'; + + public function index() + { + return 'tag-ok'; + } + })::register(); } } From 75dda54fc77a000c6635736b9bdd1f53793b3f75 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 15:51:00 -0500 Subject: [PATCH 13/22] populate the default allowed tags and modifiers --- src/Providers/ViewServiceProvider.php | 89 ++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index 657e73b6209..7c2c0fb8e81 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -102,10 +102,97 @@ 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', []); + $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', + '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); From 8428baccd014883c1254a0e4a32a16faffdebd87 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 16:00:36 -0500 Subject: [PATCH 14/22] doesnt need to change --- src/Sites/Site.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sites/Site.php b/src/Sites/Site.php index fa56fc09975..bc46e744b76 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -131,13 +131,13 @@ protected function resolveAntlersValue($value) ->all(); } - $previousIsEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; GlobalRuntimeState::$isEvaluatingUserData = true; try { return (string) app(RuntimeParser::class)->parse($value, ['config' => Cascade::config()]); } finally { - GlobalRuntimeState::$isEvaluatingUserData = $previousIsEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; } } From 66c11e29acf8a07c01f5ecb75634fb2c2be2a574 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 17:03:06 -0500 Subject: [PATCH 15/22] parser tests with tags assume trusted --- tests/Antlers/ParserTestCase.php | 48 +++++++++++++++++ tests/Antlers/ScratchTest.php | 8 +-- tests/Assets/AssetTest.php | 8 +-- tests/Data/Entries/CollectionTest.php | 5 +- tests/Fieldtypes/IconTest.php | 4 +- tests/Modifiers/CompactTest.php | 2 +- tests/Sites/SiteTest.php | 2 +- tests/StaticCaching/NocacheTagsTest.php | 2 +- tests/Tags/CacheTagTest.php | 2 +- tests/Tags/ChildrenTest.php | 2 +- tests/Tags/CookieTagTest.php | 8 +-- tests/Tags/Dictionary/DictionaryTagTest.php | 2 +- tests/Tags/Form/FormTestCase.php | 2 +- tests/Tags/GetContentTagTest.php | 2 +- tests/Tags/GetErrorTest.php | 2 +- tests/Tags/GetErrorsTest.php | 2 +- tests/Tags/GetSiteTagTest.php | 10 ++-- tests/Tags/GlideTest.php | 8 +-- tests/Tags/IncrementTest.php | 2 +- tests/Tags/InstalledTest.php | 2 +- tests/Tags/IterateTest.php | 2 +- tests/Tags/LinkTest.php | 2 +- tests/Tags/LoaderTest.php | 2 +- tests/Tags/LocalesTagTest.php | 2 +- tests/Tags/MountUrlTagTest.php | 2 +- tests/Tags/ParentTest.php | 2 +- tests/Tags/PartialTagsTest.php | 2 +- tests/Tags/PathTest.php | 2 +- tests/Tags/RangeTest.php | 2 +- tests/Tags/RedirectTest.php | 2 +- tests/Tags/SearchTest.php | 2 +- tests/Tags/SessionTagTest.php | 20 +++---- tests/Tags/StructureTagTest.php | 54 +++++++++---------- tests/Tags/SvgTagTest.php | 2 +- tests/Tags/ThemeTagsTest.php | 2 +- tests/Tags/User/ForgotPasswordFormTest.php | 2 +- tests/Tags/User/LoginFormTest.php | 2 +- tests/Tags/User/PasswordFormTest.php | 2 +- tests/Tags/User/ProfileFormTest.php | 2 +- tests/Tags/User/RegisterFormTest.php | 2 +- tests/Tags/User/ResetPasswordFormTest.php | 2 +- tests/Tags/User/UserTagsTest.php | 2 +- tests/Tags/UserGroupsTagTest.php | 2 +- tests/Tags/UserRolesTagTest.php | 2 +- tests/Tags/UsersTagsTest.php | 2 +- tests/Tags/ViteTest.php | 2 +- .../ComponentCompilerTest.php | 2 +- 47 files changed, 147 insertions(+), 100 deletions(-) diff --git a/tests/Antlers/ParserTestCase.php b/tests/Antlers/ParserTestCase.php index bb53e719549..d6dacb7eebc 100644 --- a/tests/Antlers/ParserTestCase.php +++ b/tests/Antlers/ParserTestCase.php @@ -256,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.' }}'; @@ -275,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); @@ -302,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.' }}'; @@ -327,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/ScratchTest.php b/tests/Antlers/ScratchTest.php index ea3cf7b3bf5..111e220ce81 100644 --- a/tests/Antlers/ScratchTest.php +++ b/tests/Antlers/ScratchTest.php @@ -22,7 +22,7 @@ public function tag_variables_should_not_leak_outside_its_tag_pair() $template = '{{ title }} {{ collection:test }}{{ title }} {{ /collection:test }} {{ title }}'; $expected = 'Outside One Two Outside'; - $parsed = (string) Antlers::parse($template, ['title' => 'Outside']); + $parsed = (string) Antlers::parse($template, ['title' => 'Outside'], true); $this->assertEquals($expected, $parsed); } @@ -40,9 +40,9 @@ public function interpolated_parameter_with_extra_space_should_work() { $this->app['statamic.tags']['test'] = \Tests\Fixtures\Addon\Tags\TestTags::class; - $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{bar }" }}', ['bar' => 'baz'])); - $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{ bar}" }}', ['bar' => 'baz'])); - $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{ bar }" }}', ['bar' => 'baz'])); + $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{bar }" }}', ['bar' => 'baz'], true)); + $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{ bar}" }}', ['bar' => 'baz'], true)); + $this->assertEquals('baz', (string) Antlers::parse('{{ test variable="{ bar }" }}', ['bar' => 'baz'], true)); } public function test_runtime_can_parse_expanded_ascii_characters() diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 8493c26d4f8..69646d24022 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2453,14 +2453,14 @@ public function it_augments_in_the_parser() $container->shouldReceive('url')->andReturn('/container'); $asset = (new Asset)->container($container)->path('path/to/test.txt'); - $this->assertEquals('/container/path/to/test.txt', Antlers::parse('{{ asset }}', ['asset' => $asset])); + $this->assertEquals('/container/path/to/test.txt', Antlers::parse('{{ asset }}', ['asset' => $asset], true)); - $this->assertEquals('path/to/test.txt', Antlers::parse('{{ asset }}{{ path }}{{ /asset }}', ['asset' => $asset])); + $this->assertEquals('path/to/test.txt', Antlers::parse('{{ asset }}{{ path }}{{ /asset }}', ['asset' => $asset], true)); - $this->assertEquals('test.txt', Antlers::parse('{{ asset:basename }}', ['asset' => $asset])); + $this->assertEquals('test.txt', Antlers::parse('{{ asset:basename }}', ['asset' => $asset], true)); // The "asset" Tag will output nothing when an invalid asset src is passed. It doesn't throw an exception. - $this->assertEquals('', Antlers::parse('{{ asset src="invalid" }}{{ basename }}{{ /asset }}', ['asset' => $asset])); + $this->assertEquals('', Antlers::parse('{{ asset src="invalid" }}{{ basename }}{{ /asset }}', ['asset' => $asset], true)); } #[Test] diff --git a/tests/Data/Entries/CollectionTest.php b/tests/Data/Entries/CollectionTest.php index 44060a0d9af..a62e7f0d1a4 100644 --- a/tests/Data/Entries/CollectionTest.php +++ b/tests/Data/Entries/CollectionTest.php @@ -20,7 +20,6 @@ use Statamic\Exceptions\CollectionNotFoundException; use Statamic\Facades; use Statamic\Facades\Antlers; -use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Fields\Blueprint; use Statamic\Structures\CollectionStructure; @@ -813,12 +812,12 @@ public function it_augments_in_the_parser() $this->assertEquals('test', Antlers::parse('{{ collection }}', ['collection' => $collection])); - $this->assertEquals('test Test', Antlers::parse('{{ collection }}{{ handle }} {{ title }}{{ /collection }}', ['collection' => $collection])); + $this->assertEquals('test Test', Antlers::parse('{{ collection }}{{ handle }} {{ title }}{{ /collection }}', ['collection' => $collection], true)); $this->assertEquals('test', Antlers::parse('{{ collection:handle }}', ['collection' => $collection])); try { - Antlers::parse('{{ collection from="somewhere" }}{{ title }}{{ /collection }}', ['collection' => $collection]); + Antlers::parse('{{ collection from="somewhere" }}{{ title }}{{ /collection }}', ['collection' => $collection], true); $this->fail('Exception not thrown'); } catch (CollectionNotFoundException $e) { $this->assertEquals('Collection [somewhere] not found', $e->getMessage()); diff --git a/tests/Fieldtypes/IconTest.php b/tests/Fieldtypes/IconTest.php index 13c1b6dc3b5..5d284f04b6a 100644 --- a/tests/Fieldtypes/IconTest.php +++ b/tests/Fieldtypes/IconTest.php @@ -14,7 +14,7 @@ class IconTest extends TestCase #[Test] public function it_finds_default_icons() { - $result = (string) Antlers::parse('{{ svg src="{test|raw}" }}', ['test' => new Value('add', $this->fieldtype())]); + $result = (string) Antlers::parse('{{ svg src="{test|raw}" }}', ['test' => new Value('add', $this->fieldtype())], true); $this->assertStringContainsString(' new Value('add', $this->fieldtype())]); + $result = (string) Antlers::parse('{{ svg :src="test" class="w-4 h-4" sanitize="false" }}', ['test' => new Value('add', $this->fieldtype())], true); $this->assertStringContainsString('assertSame('test', (string) $site); - $this->assertEquals('test', Antlers::parse('{{ site }}', ['site' => $site])); + $this->assertEquals('test', Antlers::parse('{{ site }}', ['site' => $site], true)); } #[Test] diff --git a/tests/StaticCaching/NocacheTagsTest.php b/tests/StaticCaching/NocacheTagsTest.php index e21daa639f9..5a377c5e9a2 100644 --- a/tests/StaticCaching/NocacheTagsTest.php +++ b/tests/StaticCaching/NocacheTagsTest.php @@ -202,6 +202,6 @@ public function it_only_adds_explicitly_defined_fields_of_context_to_session() private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } } diff --git a/tests/Tags/CacheTagTest.php b/tests/Tags/CacheTagTest.php index 390491f7e61..c1b95211158 100644 --- a/tests/Tags/CacheTagTest.php +++ b/tests/Tags/CacheTagTest.php @@ -347,6 +347,6 @@ private function tag($tag, $data = []) { GlobalRuntimeState::resetGlobalState(); - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } } diff --git a/tests/Tags/ChildrenTest.php b/tests/Tags/ChildrenTest.php index 7dc283db9d8..6452c7f17dd 100644 --- a/tests/Tags/ChildrenTest.php +++ b/tests/Tags/ChildrenTest.php @@ -29,7 +29,7 @@ public function setUp(): void private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } private function setUpEntries() diff --git a/tests/Tags/CookieTagTest.php b/tests/Tags/CookieTagTest.php index dd8a542634f..96fe3e46151 100644 --- a/tests/Tags/CookieTagTest.php +++ b/tests/Tags/CookieTagTest.php @@ -13,13 +13,13 @@ public function it_gets_cookie_value() { request()->cookies->set('nineties', 'rad'); - $this->assertEquals('rad', Antlers::parse('{{ cookie:value key="nineties" }}')); + $this->assertEquals('rad', Antlers::parse('{{ cookie:value key="nineties" }}', [], true)); } #[Test] public function it_gets_default_cookie_value() { - $this->assertEquals('1', Antlers::parse('{{ cookie:value key="nineties" default="1" }}')); + $this->assertEquals('1', Antlers::parse('{{ cookie:value key="nineties" default="1" }}', [], true)); } #[Test] @@ -27,7 +27,7 @@ public function it_gets_cookie_value_using_wildcard() { request()->cookies->set('nineties', 'rad'); - $this->assertEquals('rad', Antlers::parse('{{ cookie:nineties }}')); - $this->assertEquals('rad', Antlers::parse('{{ cookie:key }}', ['key' => 'nineties'])); + $this->assertEquals('rad', Antlers::parse('{{ cookie:nineties }}', [], true)); + $this->assertEquals('rad', Antlers::parse('{{ cookie:key }}', ['key' => 'nineties'], true)); } } diff --git a/tests/Tags/Dictionary/DictionaryTagTest.php b/tests/Tags/Dictionary/DictionaryTagTest.php index 5270ac82bbc..534efcaa2a1 100644 --- a/tests/Tags/Dictionary/DictionaryTagTest.php +++ b/tests/Tags/Dictionary/DictionaryTagTest.php @@ -84,7 +84,7 @@ public function it_can_be_filtered_using_a_query_scope() private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } } diff --git a/tests/Tags/Form/FormTestCase.php b/tests/Tags/Form/FormTestCase.php index c6ee6695acf..1629e875d6d 100644 --- a/tests/Tags/Form/FormTestCase.php +++ b/tests/Tags/Form/FormTestCase.php @@ -69,7 +69,7 @@ public function post($uri, array $data = [], array $headers = []) protected function tag($tag, $params = []) { - return Parse::template($tag, $params); + return Parse::template($tag, $params, trusted: true); } protected function createForm($blueprintContents = null, $handle = null) diff --git a/tests/Tags/GetContentTagTest.php b/tests/Tags/GetContentTagTest.php index d4656a5a5a6..e7bf160d877 100644 --- a/tests/Tags/GetContentTagTest.php +++ b/tests/Tags/GetContentTagTest.php @@ -175,6 +175,6 @@ public function it_returns_the_entries_if_theyre_already_entries_using_shorthand private function assertParseEquals($expected, $template, $context = []) { - $this->assertEquals($expected, (string) Antlers::parse($template, $context)); + $this->assertEquals($expected, (string) Antlers::parse($template, $context, true)); } } diff --git a/tests/Tags/GetErrorTest.php b/tests/Tags/GetErrorTest.php index 86e846eef23..5975c0a3781 100644 --- a/tests/Tags/GetErrorTest.php +++ b/tests/Tags/GetErrorTest.php @@ -12,7 +12,7 @@ class GetErrorTest extends TestCase { private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/GetErrorsTest.php b/tests/Tags/GetErrorsTest.php index b90edb8d244..c507524375e 100644 --- a/tests/Tags/GetErrorsTest.php +++ b/tests/Tags/GetErrorsTest.php @@ -13,7 +13,7 @@ class GetErrorsTest extends TestCase { private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/GetSiteTagTest.php b/tests/Tags/GetSiteTagTest.php index 212c812ed34..c41ca637bec 100644 --- a/tests/Tags/GetSiteTagTest.php +++ b/tests/Tags/GetSiteTagTest.php @@ -24,12 +24,12 @@ public function it_gets_site_by_handle() { $this->assertEquals( 'English', - Antlers::parse('{{ get_site handle="english" }}{{ name }}{{ /get_site }}') + Antlers::parse('{{ get_site handle="english" }}{{ name }}{{ /get_site }}', [], true) ); $this->assertEquals( 'French', - Antlers::parse('{{ get_site:french }}{{ name }}{{ /get_site:french }}') + Antlers::parse('{{ get_site:french }}{{ name }}{{ /get_site:french }}', [], true) ); } @@ -38,7 +38,7 @@ public function it_can_be_used_as_single_tag() { $this->assertEquals( 'en_US', - Antlers::parse('{{ get_site:english:locale }}') + Antlers::parse('{{ get_site:english:locale }}', [], true) ); } @@ -47,7 +47,7 @@ public function it_throws_exception_if_handle_is_missing() { $this->expectExceptionMessage('A site handle is required.'); - Antlers::parse('{{ get_site }}{{ name }}{{ /get_site }}'); + Antlers::parse('{{ get_site }}{{ name }}{{ /get_site }}', [], true); } #[Test] @@ -55,6 +55,6 @@ public function it_throws_exception_if_site_doesnt_exist() { $this->expectExceptionMessage('Site [nonexistent] does not exist.'); - Antlers::parse('{{ get_site handle="nonexistent" }}{{ name }}{{ /get_site }}'); + Antlers::parse('{{ get_site handle="nonexistent" }}{{ name }}{{ /get_site }}', [], true); } } diff --git a/tests/Tags/GlideTest.php b/tests/Tags/GlideTest.php index aae0616e1e4..920aa24a253 100644 --- a/tests/Tags/GlideTest.php +++ b/tests/Tags/GlideTest.php @@ -50,7 +50,7 @@ public function it_outputs_an_absolute_url_by_default_when_the_glide_route_is_ab */ public function it_outputs_an_absolute_url_when_the_url_does_not_have_a_valid_extension() { - $parse = (string) Parse::template('{{ glide src="https://statamic.com/foo" }}'); + $parse = (string) Parse::template('{{ glide src="https://statamic.com/foo" }}', trusted: true); $this->assertSame('https://statamic.com/foo', $parse); } @@ -64,7 +64,7 @@ public function it_outputs_a_data_url() {{ glide:data_url :src="foo" }} EOT; - $this->assertStringStartsWith('data:image/jpeg;base64', (string) Parse::template($tag, ['foo' => 'bar.jpg'])); + $this->assertStringStartsWith('data:image/jpeg;base64', (string) Parse::template($tag, ['foo' => 'bar.jpg'], trusted: true)); } #[Test] @@ -76,7 +76,7 @@ public function it_treats_assets_urls_starting_with_the_app_url_as_internal_asse { $this->createImageInPublicDirectory(); - $result = (string) Parse::template('{{ glide:foo width="100" }}', ['foo' => 'http://localhost/glide/bar.jpg']); + $result = (string) Parse::template('{{ glide:foo width="100" }}', ['foo' => 'http://localhost/glide/bar.jpg'], trusted: true); $this->assertStringStartsWith('/img/glide/bar.jpg', $result); } @@ -132,6 +132,6 @@ private function absoluteTestTag($absolute = null) {{ glide:foo width="100" $absoluteParam }} EOT; - return (string) Parse::template($tag, ['foo' => 'bar.jpg']); + return (string) Parse::template($tag, ['foo' => 'bar.jpg'], trusted: true); } } diff --git a/tests/Tags/IncrementTest.php b/tests/Tags/IncrementTest.php index 83c643dbf32..5f28e4c7689 100644 --- a/tests/Tags/IncrementTest.php +++ b/tests/Tags/IncrementTest.php @@ -29,7 +29,7 @@ class IncrementTest extends TestCase private function tag($tag, $context = []) { - return (string) Parse::template($tag, $context); + return (string) Parse::template($tag, $context, trusted: true); } #[Test] diff --git a/tests/Tags/InstalledTest.php b/tests/Tags/InstalledTest.php index 37a99c859c9..481d653cda9 100644 --- a/tests/Tags/InstalledTest.php +++ b/tests/Tags/InstalledTest.php @@ -18,7 +18,7 @@ public function setUp(): void private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/IterateTest.php b/tests/Tags/IterateTest.php index 227cafda860..c2cc8e967ef 100644 --- a/tests/Tags/IterateTest.php +++ b/tests/Tags/IterateTest.php @@ -46,6 +46,6 @@ public static function iterateProvider() private function tag($tag, $context = []) { - return (string) Parse::template($tag, $context); + return (string) Parse::template($tag, $context, trusted: true); } } diff --git a/tests/Tags/LinkTest.php b/tests/Tags/LinkTest.php index 86bd867ac66..e4b6e39d4e0 100644 --- a/tests/Tags/LinkTest.php +++ b/tests/Tags/LinkTest.php @@ -23,7 +23,7 @@ public function setUp(): void private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/LoaderTest.php b/tests/Tags/LoaderTest.php index 93d1d0e512a..a6d3b500e79 100644 --- a/tests/Tags/LoaderTest.php +++ b/tests/Tags/LoaderTest.php @@ -25,7 +25,7 @@ public function loading_a_tag_will_run_the_init_hook() return $next($payload); }); - $this->assertEquals('bar', (string) Antlers::parse('{{ test :variable="foo" }}', ['foo' => 'bar'])); + $this->assertEquals('bar', (string) Antlers::parse('{{ test :variable="foo" }}', ['foo' => 'bar'], true)); $this->assertEquals(['variable' => 'bar', 'alfa' => 'bravo'], $tag->params->all()); } } diff --git a/tests/Tags/LocalesTagTest.php b/tests/Tags/LocalesTagTest.php index dc4ece60c2a..f666ff48982 100644 --- a/tests/Tags/LocalesTagTest.php +++ b/tests/Tags/LocalesTagTest.php @@ -60,7 +60,7 @@ public function setUp(): void private function tag($tag, $context = []) { - return (string) Parse::template($tag, $context); + return (string) Parse::template($tag, $context, trusted: true); } #[Test] diff --git a/tests/Tags/MountUrlTagTest.php b/tests/Tags/MountUrlTagTest.php index 556c01bf48b..ca9d0bb149d 100644 --- a/tests/Tags/MountUrlTagTest.php +++ b/tests/Tags/MountUrlTagTest.php @@ -78,6 +78,6 @@ public static function mountProvider() private function assertParseEquals($expected, $template, $context = []) { - $this->assertEquals($expected, (string) Antlers::parse($template, $context)); + $this->assertEquals($expected, (string) Antlers::parse($template, $context, true)); } } diff --git a/tests/Tags/ParentTest.php b/tests/Tags/ParentTest.php index 65e959d3ebc..818b1ffefe7 100644 --- a/tests/Tags/ParentTest.php +++ b/tests/Tags/ParentTest.php @@ -25,7 +25,7 @@ public function setUp(): void private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } private function setUpEntries() diff --git a/tests/Tags/PartialTagsTest.php b/tests/Tags/PartialTagsTest.php index fb259257a35..125982408ea 100644 --- a/tests/Tags/PartialTagsTest.php +++ b/tests/Tags/PartialTagsTest.php @@ -19,7 +19,7 @@ public function setUp(): void private function tag($tag, $context = []) { - return (string) Parse::template($tag, $context); + return (string) Parse::template($tag, $context, trusted: true); } protected function partialTag($src, $params = '') diff --git a/tests/Tags/PathTest.php b/tests/Tags/PathTest.php index aa56d7e3833..9bfd06ad057 100644 --- a/tests/Tags/PathTest.php +++ b/tests/Tags/PathTest.php @@ -22,7 +22,7 @@ public function setUp(): void private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } private function setSiteUrl($url) diff --git a/tests/Tags/RangeTest.php b/tests/Tags/RangeTest.php index 2b40d2e959d..995b071a596 100644 --- a/tests/Tags/RangeTest.php +++ b/tests/Tags/RangeTest.php @@ -10,7 +10,7 @@ class RangeTest extends TestCase { private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/RedirectTest.php b/tests/Tags/RedirectTest.php index f109fe64eb3..ec715fceb5d 100644 --- a/tests/Tags/RedirectTest.php +++ b/tests/Tags/RedirectTest.php @@ -34,7 +34,7 @@ protected function resolveApplicationConfiguration($app) private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/SearchTest.php b/tests/Tags/SearchTest.php index 67dd8360442..15c316bb41b 100644 --- a/tests/Tags/SearchTest.php +++ b/tests/Tags/SearchTest.php @@ -16,7 +16,7 @@ class SearchTest extends TestCase private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/Tags/SessionTagTest.php b/tests/Tags/SessionTagTest.php index e98139a6659..5d19c3c1bb6 100644 --- a/tests/Tags/SessionTagTest.php +++ b/tests/Tags/SessionTagTest.php @@ -13,7 +13,7 @@ public function it_gets_session_value() { session()->put('nineties', 'rad'); - $this->assertEquals('rad', Antlers::parse('{{ session:value key="nineties" }}')); + $this->assertEquals('rad', Antlers::parse('{{ session:value key="nineties" }}', [], true)); } #[Test] @@ -21,8 +21,8 @@ public function it_gets_session_array_value() { session()->put('things', ['nineties' => 'rad']); - $this->assertEquals('rad', Antlers::parse('{{ session:value key="things.nineties" }}')); - $this->assertEquals('rad', Antlers::parse('{{ session:value key="things:nineties" }}')); + $this->assertEquals('rad', Antlers::parse('{{ session:value key="things.nineties" }}', [], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:value key="things:nineties" }}', [], true)); } #[Test] @@ -30,9 +30,9 @@ public function it_gets_session_value_using_wildcard() { session()->put('nineties', 'rad'); - $this->assertEquals('rad', Antlers::parse('{{ session:nineties }}')); - $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'nineties'])); - $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'nineties'])); + $this->assertEquals('rad', Antlers::parse('{{ session:nineties }}', [], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'nineties'], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'nineties'], true)); } #[Test] @@ -40,9 +40,9 @@ public function it_gets_session_array_value_using_wildcard() { session()->put('things', ['nineties' => 'rad']); - $this->assertEquals('rad', Antlers::parse('{{ session:things.nineties }}')); - $this->assertEquals('rad', Antlers::parse('{{ session:things:nineties }}')); - $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'things.nineties'])); - $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'things:nineties'])); + $this->assertEquals('rad', Antlers::parse('{{ session:things.nineties }}', [], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:things:nineties }}', [], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'things.nineties'], true)); + $this->assertEquals('rad', Antlers::parse('{{ session:key }}', ['key' => 'things:nineties'], true)); } } diff --git a/tests/Tags/StructureTagTest.php b/tests/Tags/StructureTagTest.php index 03baf77b66b..87837673310 100644 --- a/tests/Tags/StructureTagTest.php +++ b/tests/Tags/StructureTagTest.php @@ -78,7 +78,7 @@ public function it_renders_a_nav() $this->assertXmlStringEqualsXmlString($expected, (string) Antlers::parse($template, [ 'foo' => 'bar', // to test that cascade is inherited. 'title' => 'outer title', // to test that cascade the page's data takes precedence over the cascading data. - ])); + ], true)); } #[Test] @@ -143,7 +143,7 @@ public function it_renders_a_nav_with_selected_fields() $parsed = (string) Antlers::parse($template, [ 'foo' => 'bar', // to test that cascade is inherited. 'title' => 'outer title', // to test that cascade the page's data takes precedence over the cascading data. - ]); + ], true); // This is really what we're interested in testing. The "Two" entry has a foo value // of "notbar", but we're only selecting the title, so we shouldn't get the real value. @@ -217,7 +217,7 @@ public function it_renders_a_nav_with_scope() 'foo' => 'bar', // to test that cascade is inherited. 'title' => 'outer title', // to test that cascade the page's data takes precedence over the cascading data. 'nav_title' => 'outer nav_title', // to test that the cascade doesn't leak into the iterated scope - ])); + ], true)); } #[Test] @@ -259,7 +259,7 @@ public function it_renders_a_nav_with_as() $this->assertXmlStringEqualsXmlString($expected, (string) Antlers::parse($template, [ 'foo' => 'bar', // to test that cascade is inherited. - ])); + ], true)); } #[Test] @@ -338,31 +338,31 @@ public function it_sets_is_current_and_is_parent_for_a_nav() ]); $mock->shouldReceive('getCurrent')->once()->andReturn('/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1=current][1-1][1-1-1][1-1-1-1][2][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1=parent][1-1=current][1-1-1][1-1-1-1][2][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1=parent][1-1=parent][1-1-1=current][1-1-1-1][2][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1=parent][1-1=parent][1-1-1=parent][1-1-1-1=current][2][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/2'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1][1-1][1-1-1][1-1-1-1][2=current][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=current][1][1-1][1-1-1][1-1-1-1][2][3]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/other'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[home=parent][1][1-1][1-1-1][1-1-1-1][2][3]', $result); // Only the last child has an URL. @@ -377,15 +377,15 @@ public function it_sets_is_current_and_is_parent_for_a_nav() ]); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=parent][1-1=parent][1-1-1=parent][1-1-1-1=current]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/other'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1]', $result); // Only the top parent has an URL. @@ -400,15 +400,15 @@ public function it_sets_is_current_and_is_parent_for_a_nav() ]); $mock->shouldReceive('getCurrent')->once()->andReturn('/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=current][1-1][1-1-1][1-1-1-1]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/other'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1]', $result); } @@ -430,7 +430,7 @@ public function it_sets_is_parent_based_on_the_url_too() EntryFactory::collection('rad')->id('3')->slug('3')->data(['title' => 'Three'])->create(); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/2/3'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=parent][2=parent]', $result); } @@ -470,31 +470,31 @@ public function it_sets_is_current_and_is_parent_for_a_collection() \Statamic\Facades\URL::swap($mock); $mock->shouldReceive('getCurrent')->once()->andReturn('/'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1][2]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/other'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1][2]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/2'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1][1-1][1-1-1][1-1-1-1][2=current]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=current][1-1][1-1-1][1-1-1-1][2]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=parent][1-1=current][1-1-1][1-1-1-1][2]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=parent][1-1=parent][1-1-1=current][1-1-1-1][2]', $result); $mock->shouldReceive('getCurrent')->once()->andReturn('/1/1/1/1'); - $result = (string) Antlers::parse($template); + $result = (string) Antlers::parse($template, [], true); $this->assertEquals('[1=parent][1-1=parent][1-1-1=parent][1-1-1-1=current][2]', $result); } @@ -530,7 +530,7 @@ public function it_doesnt_render_anything_when_nav_from_is_invalid() $this->assertXmlStringEqualsXmlString($expected, (string) Antlers::parse($template, [ 'title' => 'outer title', // to test that cascade the page's data takes precedence over the cascading data. - ])); + ], true)); } private function makeNav($tree) @@ -544,7 +544,7 @@ private function makeNav($tree) private function parseBasicTemplate($handle, $params = null) { - return (string) Antlers::parse($this->createBasicTemplate($handle, $params)); + return (string) Antlers::parse($this->createBasicTemplate($handle, $params), [], true); } private function createBasicTemplate($handle, $params = null) diff --git a/tests/Tags/SvgTagTest.php b/tests/Tags/SvgTagTest.php index 47b17e25a43..d8d90a6b981 100644 --- a/tests/Tags/SvgTagTest.php +++ b/tests/Tags/SvgTagTest.php @@ -20,7 +20,7 @@ public function setUp(): void private function tag($tag, $variables = []) { - return Parse::template($tag, $variables); + return Parse::template($tag, $variables, trusted: true); } #[Test] diff --git a/tests/Tags/ThemeTagsTest.php b/tests/Tags/ThemeTagsTest.php index 0c57b50caef..81e628cb787 100644 --- a/tests/Tags/ThemeTagsTest.php +++ b/tests/Tags/ThemeTagsTest.php @@ -21,7 +21,7 @@ public function setUp(): void private function tag($tag): string { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } public function testOutputsThemedJs() diff --git a/tests/Tags/User/ForgotPasswordFormTest.php b/tests/Tags/User/ForgotPasswordFormTest.php index 4c2ab6c4cb9..132b0637b56 100644 --- a/tests/Tags/User/ForgotPasswordFormTest.php +++ b/tests/Tags/User/ForgotPasswordFormTest.php @@ -16,7 +16,7 @@ class ForgotPasswordFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/LoginFormTest.php b/tests/Tags/User/LoginFormTest.php index 103a72c0b01..bef0c96530a 100644 --- a/tests/Tags/User/LoginFormTest.php +++ b/tests/Tags/User/LoginFormTest.php @@ -15,7 +15,7 @@ class LoginFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/PasswordFormTest.php b/tests/Tags/User/PasswordFormTest.php index 35855b1051d..14652cab5a5 100644 --- a/tests/Tags/User/PasswordFormTest.php +++ b/tests/Tags/User/PasswordFormTest.php @@ -16,7 +16,7 @@ class PasswordFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/ProfileFormTest.php b/tests/Tags/User/ProfileFormTest.php index e7276ccf239..edbdba5e96c 100644 --- a/tests/Tags/User/ProfileFormTest.php +++ b/tests/Tags/User/ProfileFormTest.php @@ -16,7 +16,7 @@ class ProfileFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/RegisterFormTest.php b/tests/Tags/User/RegisterFormTest.php index f86fcec19fb..7d3114a97a4 100644 --- a/tests/Tags/User/RegisterFormTest.php +++ b/tests/Tags/User/RegisterFormTest.php @@ -19,7 +19,7 @@ class RegisterFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/ResetPasswordFormTest.php b/tests/Tags/User/ResetPasswordFormTest.php index 035f9404891..941ab966f38 100644 --- a/tests/Tags/User/ResetPasswordFormTest.php +++ b/tests/Tags/User/ResetPasswordFormTest.php @@ -13,7 +13,7 @@ class ResetPasswordFormTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/User/UserTagsTest.php b/tests/Tags/User/UserTagsTest.php index 2f1a9f2a85f..3f1cdb73efd 100644 --- a/tests/Tags/User/UserTagsTest.php +++ b/tests/Tags/User/UserTagsTest.php @@ -22,7 +22,7 @@ class UserTagsTest extends TestCase private function tag($tag, $params = []) { - return Parse::template($tag, $params); + return Parse::template($tag, $params, trusted: true); } #[Test] diff --git a/tests/Tags/UserGroupsTagTest.php b/tests/Tags/UserGroupsTagTest.php index 362bbdb430a..e9c366ed418 100644 --- a/tests/Tags/UserGroupsTagTest.php +++ b/tests/Tags/UserGroupsTagTest.php @@ -62,6 +62,6 @@ public function it_outputs_no_results_when_finding_multiple_groups() private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } } diff --git a/tests/Tags/UserRolesTagTest.php b/tests/Tags/UserRolesTagTest.php index d413d791f04..fcd4bffe6b4 100644 --- a/tests/Tags/UserRolesTagTest.php +++ b/tests/Tags/UserRolesTagTest.php @@ -62,6 +62,6 @@ public function it_outputs_no_results_when_finding_multiple_roles() private function tag($tag, $data = []) { - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } } diff --git a/tests/Tags/UsersTagsTest.php b/tests/Tags/UsersTagsTest.php index 616979af82c..d471e278d68 100644 --- a/tests/Tags/UsersTagsTest.php +++ b/tests/Tags/UsersTagsTest.php @@ -18,7 +18,7 @@ class UsersTagsTest extends TestCase private function tag($tag) { - return Parse::template($tag, []); + return Parse::template($tag, trusted: true); } #[Test] diff --git a/tests/Tags/ViteTest.php b/tests/Tags/ViteTest.php index cf642b00b6e..48c850d8f70 100644 --- a/tests/Tags/ViteTest.php +++ b/tests/Tags/ViteTest.php @@ -19,7 +19,7 @@ private function tag($tag, $data = []) { $this->withFakeVite(); - return (string) Parse::template($tag, $data); + return (string) Parse::template($tag, $data, trusted: true); } #[Test] diff --git a/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php b/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php index e4a6f7dd005..ccf54e3f89f 100644 --- a/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php +++ b/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php @@ -328,7 +328,7 @@ public function index() $this->assertSame( 'Hello, Antlers!', - (string) Antlers::parse('{{ my_tag }}'), + (string) Antlers::parse('{{ my_tag }}', [], true), ); } From 0691766997881962ef1f3039ebcd5eff1f02aa84 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 17:06:04 -0500 Subject: [PATCH 16/22] partial modifier parses a file on disk so its trusted --- src/Modifiers/CoreModifiers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index b6f8e5449b9..b455827d40f 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -1872,7 +1872,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); } /** From 8dc34cbfe26c8b4df47804f3bf63a41bf4dd605c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 17:33:59 -0500 Subject: [PATCH 17/22] use modifiers in test that arent blocked --- tests/Fieldtypes/MarkdownTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Fieldtypes/MarkdownTest.php b/tests/Fieldtypes/MarkdownTest.php index ae4848da8b7..cca36bfd157 100644 --- a/tests/Fieldtypes/MarkdownTest.php +++ b/tests/Fieldtypes/MarkdownTest.php @@ -177,13 +177,13 @@ public function it_converts_to_smartypants_after_antlers_is_parsed() $md = $this->fieldtype(['smartypants' => true, 'antlers' => true]); $value = <<<'EOT' -{{ "this is a string" | replace(" is ", " isnt ") | reverse }} +{{ "this is a string" | replace(" is ", " isnt ") | upper }} EOT; $value = new Value($value, 'markdown', $md); $expected = <<<'EOT' -

gnirts a tnsi siht

+

THIS ISNT A STRING

EOT; $this->assertEqualsTrimmed($expected, $value->antlersValue(app(\Statamic\Contracts\View\Antlers\Parser::class), [])); From 30b234f88badcd0fb062143d74d9d89a9a62a3ba Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 21:50:38 -0500 Subject: [PATCH 18/22] fix test by trusting --- tests/Tags/ParametersTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Tags/ParametersTest.php b/tests/Tags/ParametersTest.php index 3cb4ef5f84e..116c848cbf6 100644 --- a/tests/Tags/ParametersTest.php +++ b/tests/Tags/ParametersTest.php @@ -7,6 +7,7 @@ use Statamic\Fields\Value; use Statamic\Tags\Context; use Statamic\Tags\Parameters; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Tests\TestCase; class ParametersTest extends TestCase @@ -65,6 +66,13 @@ public function augment($value) ], $context); } + public function tearDown(): void + { + GlobalRuntimeState::$isEvaluatingUserData = true; + + parent::tearDown(); + } + #[Test] public function it_gets_all_parameters() { @@ -243,6 +251,9 @@ public function augmentedArrayData() #[Test] public function it_can_use_modifiers() { + // Equivalent to "trusted = true" for runtime evaluation. + GlobalRuntimeState::$isEvaluatingUserData = false; + $context = new Context(['foo' => 'bar']); $params = Parameters::make([ From 0e733ab86bef9027bfb29e21f2c159fe3e4d511c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 26 Feb 2026 22:53:56 -0500 Subject: [PATCH 19/22] join is ok --- src/Providers/ViewServiceProvider.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index 7c2c0fb8e81..3da868a5c41 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -149,6 +149,7 @@ private function registerAntlers() 'is_numeric', 'is_uppercase', 'is_url', + 'join', 'kebab', 'lcfirst', 'localize', From 76bb1ce1326d7755ed4e576e56bd008db9578b37 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 27 Feb 2026 00:42:53 -0500 Subject: [PATCH 20/22] antlers modifier can opt into trusted mode but only if already in a trusted context --- src/Modifiers/CoreModifiers.php | 5 ++++- tests/Modifiers/AntlersTest.php | 39 +++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index b455827d40f..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); } /** diff --git a/tests/Modifiers/AntlersTest.php b/tests/Modifiers/AntlersTest.php index 2141ab96434..64c9b2654bc 100644 --- a/tests/Modifiers/AntlersTest.php +++ b/tests/Modifiers/AntlersTest.php @@ -4,10 +4,19 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Modifiers\Modify; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Tests\TestCase; class AntlersTest extends TestCase { + public function tearDown(): void + { + GlobalRuntimeState::$allowedContentModifierPaths = []; + GlobalRuntimeState::$isEvaluatingUserData = true; + + parent::tearDown(); + } + #[Test] public function it_parses_as_antlers(): void { @@ -15,8 +24,34 @@ public function it_parses_as_antlers(): void $this->assertEquals('foo alfa bar bravo', $modified); } - private function modify($value, array $context = []) + #[Test] + public function trusted_argument_does_not_escalate_when_current_runtime_is_untrusted(): void + { + GlobalRuntimeState::$isEvaluatingUserData = true; + + // AntlersFacade::shouldReceive('parse') + // ->once() + // ->with('{{ foo }}', ['foo' => 'bar'], false) + // ->andReturn('parsed'); + + $this->assertSame('foo bar ', $this->modify('foo {{ foo }} {{$ "hello" $}}', ['foo' => 'bar'], ['trusted'])); + } + + #[Test] + public function trusted_argument_parses_in_trusted_mode_when_current_runtime_is_already_trusted(): void + { + GlobalRuntimeState::$isEvaluatingUserData = false; + + // AntlersFacade::shouldReceive('parse') + // ->once() + // ->with('{{ foo }}', ['foo' => 'bar'], true) + // ->andReturn('parsed'); + + $this->assertSame('foo bar hello', $this->modify('foo {{ foo }} {{$ "hello" $}}', ['foo' => 'bar'], ['trusted'])); + } + + private function modify($value, array $context = [], array $params = []) { - return Modify::value($value)->context($context)->antlers()->fetch(); + return Modify::value($value)->context($context)->antlers($params)->fetch(); } } From 55722e5baf408a865db0d6e0bf51908f1d73d80d Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 27 Feb 2026 00:53:15 -0500 Subject: [PATCH 21/22] this didnt need to change --- .../Antlers/Language/Runtime/Sandbox/Environment.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index 42c1b665fd7..4c7f855a8e1 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -891,20 +891,16 @@ public function process($nodes) continue; } elseif ($currentNode instanceof MethodInvocationNode) { - $isMethodCallDisabled = GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent; - - if ($isMethodCallDisabled) { + if (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent) { array_pop($stack); - if (GlobalRuntimeState::$isEvaluatingUserData - && ! GlobalRuntimeState::$allowMethodsInContent - && GlobalRuntimeState::$throwErrorOnAccessViolation) { + if (GlobalRuntimeState::$throwErrorOnAccessViolation) { throw ErrorFactory::makeRuntimeError( AntlersErrorCodes::RUNTIME_METHOD_CALL_USER_CONTENT, $currentNode, 'Method invocation in user content.' ); - } elseif (GlobalRuntimeState::$isEvaluatingUserData) { + } else { Log::warning('Method call evaluated in user content.', [ 'file' => GlobalRuntimeState::$currentExecutionFile, 'trace' => GlobalRuntimeState::$templateFileStack, From f90ed3730842a5fc7970b882315bce8eef0d7575 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 27 Feb 2026 01:04:31 -0500 Subject: [PATCH 22/22] wip --- tests/Modifiers/AntlersTest.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/Modifiers/AntlersTest.php b/tests/Modifiers/AntlersTest.php index 64c9b2654bc..534c7ab87a1 100644 --- a/tests/Modifiers/AntlersTest.php +++ b/tests/Modifiers/AntlersTest.php @@ -29,11 +29,6 @@ public function trusted_argument_does_not_escalate_when_current_runtime_is_untru { GlobalRuntimeState::$isEvaluatingUserData = true; - // AntlersFacade::shouldReceive('parse') - // ->once() - // ->with('{{ foo }}', ['foo' => 'bar'], false) - // ->andReturn('parsed'); - $this->assertSame('foo bar ', $this->modify('foo {{ foo }} {{$ "hello" $}}', ['foo' => 'bar'], ['trusted'])); } @@ -42,11 +37,6 @@ public function trusted_argument_parses_in_trusted_mode_when_current_runtime_is_ { GlobalRuntimeState::$isEvaluatingUserData = false; - // AntlersFacade::shouldReceive('parse') - // ->once() - // ->with('{{ foo }}', ['foo' => 'bar'], true) - // ->andReturn('parsed'); - $this->assertSame('foo bar hello', $this->modify('foo {{ foo }} {{$ "hello" $}}', ['foo' => 'bar'], ['trusted'])); }