diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 197c7bb066f..3042b9a594c 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -52,6 +52,7 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; +use Statamic\View\Cascade; class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Contract, Localization, Protectable, ResolvesValuesContract, Responsable, SearchableContract { @@ -1040,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::parse($format, $this->augmented()->except('slug')->all())); + $title = $this->withLocale($this->site()->lang(), fn () => (string) Antlers::parseUserContent($format, $this->augmented()->except('slug')->all())); return trim($title); } @@ -1064,8 +1065,8 @@ private function resolvePreviewTargetUrl($format) }, $format); } - return (string) Antlers::parse($format, array_merge($this->routeData(), [ - 'config' => config()->all(), + return (string) Antlers::parseUserContent($format, array_merge($this->routeData(), [ + 'config' => Cascade::config(), 'site' => $this->site(), 'uri' => $this->uri(), 'url' => $this->url(), diff --git a/src/Facades/Antlers.php b/src/Facades/Antlers.php index 565291af83d..e29d69889bd 100644 --- a/src/Facades/Antlers.php +++ b/src/Facades/Antlers.php @@ -10,6 +10,7 @@ * @method static Parser parser() * @method static mixed usingParser(Parser $parser, \Closure $callback) * @method static AntlersString parse(string $str, array $variables = []) + * @method static AntlersString parseUserContent(string $str, array $variables = []) * @method static string parseLoop(string $content, array $data, bool $supplement = true, array $context = []) * @method static array identifiers(string $content) * diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 55a4320f597..0ce40bf5671 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -15,6 +15,7 @@ use Statamic\Sites\Site; use Statamic\Support\Arr; use Statamic\Support\Str; +use Statamic\View\Cascade; use function Statamic\trans as __; @@ -170,7 +171,7 @@ protected function addData() $data = array_merge($augmented, $this->getGlobalsData(), [ 'form_config' => $formConfig, 'email_config' => $this->config, - 'config' => config()->all(), + 'config' => Cascade::config(), 'fields' => $fields, 'site_url' => Config::getSiteUrl(), 'date' => now(), @@ -244,8 +245,8 @@ protected function parseConfig(array $config) return collect($config)->map(function ($value) { $value = Parse::env($value); // deprecated - return (string) Antlers::parse($value, array_merge( - ['config' => config()->all()], + return (string) Antlers::parseUserContent($value, array_merge( + ['config' => Cascade::config()], $this->getGlobalsData(), $this->submissionData, )); diff --git a/src/Sites/Site.php b/src/Sites/Site.php index 75d4c777311..bc46e744b76 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -7,7 +7,9 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\TextDirection; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Antlers\Language\Runtime\RuntimeParser; +use Statamic\View\Cascade; class Site implements Augmentable { @@ -129,7 +131,14 @@ protected function resolveAntlersValue($value) ->all(); } - return (string) app(RuntimeParser::class)->parse($value, ['config' => config()->all()]); + $isEvaluatingUserData = GlobalRuntimeState::$isEvaluatingUserData; + GlobalRuntimeState::$isEvaluatingUserData = true; + + try { + return (string) app(RuntimeParser::class)->parse($value, ['config' => Cascade::config()]); + } finally { + GlobalRuntimeState::$isEvaluatingUserData = $isEvaluatingUserData; + } } private function removePath($url) diff --git a/src/View/Antlers/Antlers.php b/src/View/Antlers/Antlers.php index b0fc4f9c43a..b1dc611cfbc 100644 --- a/src/View/Antlers/Antlers.php +++ b/src/View/Antlers/Antlers.php @@ -5,6 +5,7 @@ use Closure; use Statamic\Contracts\View\Antlers\Parser; use Statamic\View\Antlers\Language\Parser\IdentifierFinder; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; class Antlers { @@ -31,6 +32,18 @@ public function parse($str, $variables = []) return $this->parser()->parse($str, $variables); } + 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/src/View/Cascade.php b/src/View/Cascade.php index 32dd9cc0eb5..e16fa127126 100644 --- a/src/View/Cascade.php +++ b/src/View/Cascade.php @@ -183,7 +183,7 @@ private function contextualVariables() 'xml_header' => '', // @TODO remove and document new best practice 'csrf_token' => csrf_token(), 'csrf_field' => csrf_field(), - 'config' => config()->all(), + 'config' => static::config(), 'response_code' => 200, // Auth @@ -247,4 +247,141 @@ public function clearSections() return $this; } + + public static function config(): array + { + $defaults = [ + 'app.name', + 'app.env', + 'app.debug', + 'app.url', + 'app.asset_url', + 'app.locale', + 'app.fallback_locale', + 'app.timezone', + 'auth.defaults', + 'auth.guards', + 'auth.passwords', + 'broadcasting.default', + 'cache.default', + 'filesystems.default', + 'mail.default', + 'mail.from', + 'queue.default', + 'session.lifetime', + 'session.expire_on_close', + 'session.driver', + 'statamic.assets.image_manipulation', + 'statamic.assets.auto_crop', + 'statamic.assets.thumbnails', + 'statamic.assets.video_thumbnails', + 'statamic.assets.google_docs_viewer', + 'statamic.assets.cache_meta', + 'statamic.assets.focal_point_editor', + 'statamic.assets.lowercase', + 'statamic.assets.svg_sanitization_on_upload', + 'statamic.assets.ffmpeg', + 'statamic.assets.set_preview_images', + 'statamic.autosave', + 'statamic.cp', + 'statamic.editions', + 'statamic.forms.email_view_folder', + 'statamic.forms.send_email_job', + 'statamic.forms.exporters', + 'statamic.git.enabled', + 'statamic.git.automatic', + 'statamic.git.queue_connection', + 'statamic.git.dispatch_delay', + 'statamic.git.use_authenticated', + 'statamic.git.user', + 'statamic.git.binary', + 'statamic.git.commands', + 'statamic.git.push', + 'statamic.git.ignored_events', + 'statamic.git.locale', + 'statamic.graphql', + 'statamic.live_preview', + 'statamic.markdown', + 'statamic.oauth', + 'statamic.protect.default', + 'statamic.revisions', + 'statamic.routes', + 'statamic.search.default', + 'statamic.search.indexes', + 'statamic.search.defaults', + 'statamic.search.queue', + 'statamic.search.queue_connection', + 'statamic.search.chunk_size', + 'statamic.stache.watcher', + 'statamic.stache.cache_store', + 'statamic.stache.indexes', + 'statamic.stache.lock', + 'statamic.stache.warming', + 'statamic.static_caching.strategy', + 'statamic.static_caching.strategies', + 'statamic.static_caching.exclude', + 'statamic.static_caching.invalidation', + 'statamic.static_caching.ignore_query_strings', + 'statamic.static_caching.allowed_query_strings', + 'statamic.static_caching.disallowed_query_strings', + 'statamic.static_caching.nocache', + 'statamic.static_caching.nocache_db_connection', + 'statamic.static_caching.replacers', + 'statamic.static_caching.warm_queue', + 'statamic.static_caching.warm_queue_connection', + 'statamic.static_caching.warm_insecure', + 'statamic.static_caching.background_recache', + 'statamic.static_caching.recache_token_parameter', + 'statamic.static_caching.share_errors', + 'statamic.system.multisite', + 'statamic.system.send_powered_by_header', + 'statamic.system.date_format', + 'statamic.system.display_timezone', + 'statamic.system.localize_dates_in_modifiers', + 'statamic.system.charset', + 'statamic.system.track_last_update', + 'statamic.system.cache_tags_enabled', + 'statamic.system.php_memory_limit', + 'statamic.system.php_max_execution_time', + 'statamic.system.ajax_timeout', + 'statamic.system.pcre_backtrack_limit', + 'statamic.system.debugbar', + 'statamic.system.ascii_replace_extra_symbols', + 'statamic.system.update_references', + 'statamic.system.always_augment_to_query', + 'statamic.system.row_id_handle', + 'statamic.system.fake_sql_queries', + 'statamic.system.layout', + 'statamic.templates', + 'statamic.users.repository', + 'statamic.users.avatars', + 'statamic.users.new_user_roles', + 'statamic.users.new_user_groups', + 'statamic.users.wizard_invitation', + 'statamic.users.passwords', + 'statamic.users.database', + 'statamic.users.tables', + 'statamic.users.guards', + 'statamic.users.impersonate', + 'statamic.users.elevated_session_duration', + 'statamic.users.two_factor_enforced_roles', + 'statamic.users.sort_field', + 'statamic.users.sort_direction', + 'statamic.webauthn', + ]; + + $allowed = collect((array) config('statamic.system.view_config_allowlist', $defaults)) + ->flatMap(fn ($key) => $key === '@default' ? $defaults : [$key]) + ->unique()->values()->all(); + + return array_reduce($allowed, function ($config, $key) { + $value = config($key); + + if (! is_null($value)) { + Arr::set($config, $key, $value); + } + + return $config; + }, []); + } } diff --git a/tests/Antlers/ParseUserContentTest.php b/tests/Antlers/ParseUserContentTest.php new file mode 100644 index 00000000000..5177ee45152 --- /dev/null +++ b/tests/Antlers/ParseUserContentTest.php @@ -0,0 +1,91 @@ +assertSame( + (string) Antlers::parse('Hello {{ name }}!', ['name' => 'Jason']), + (string) Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']) + ); + } + + #[Test] + public function it_blocks_php_nodes_in_user_content_mode() + { + Log::shouldReceive('warning') + ->once() + ->with('PHP Node evaluated in user content: {{? echo Str::upper(\'hello\') ?}}', \Mockery::type('array')); + + $result = (string) Antlers::parseUserContent('Text: {{? echo Str::upper(\'hello\') ?}}'); + + $this->assertSame('Text: ', $result); + } + + #[Test] + public function it_blocks_method_calls_in_user_content_mode() + { + Log::shouldReceive('warning') + ->once() + ->with('Method call evaluated in user content.', \Mockery::type('array')); + + $result = (string) Antlers::parseUserContent('{{ object:method("hello") }}', [ + 'object' => new ClassOne(), + ]); + + $this->assertSame('', $result); + } + + #[Test] + public function it_restores_user_data_flag_after_successful_parse() + { + GlobalRuntimeState::$isEvaluatingUserData = false; + + Antlers::parseUserContent('Hello {{ name }}!', ['name' => 'Jason']); + + $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); + } + + #[Test] + public function it_restores_user_data_flag_after_parse_exceptions() + { + GlobalRuntimeState::$isEvaluatingUserData = false; + $parser = \Mockery::mock(Parser::class); + $parser->shouldReceive('parse') + ->once() + ->andThrow(new \RuntimeException('Failed to parse user content.')); + + try { + Antlers::usingParser($parser, function ($antlers) { + $antlers->parseUserContent('Hello {{ name }}', ['name' => 'Jason']); + }); + + $this->fail('Expected RuntimeException to be thrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('Failed to parse user content.', $exception->getMessage()); + } + + $this->assertFalse(GlobalRuntimeState::$isEvaluatingUserData); + } +} diff --git a/tests/Sites/SitesConfigTest.php b/tests/Sites/SitesConfigTest.php index 5419d31a2ad..c10f2cb73b8 100644 --- a/tests/Sites/SitesConfigTest.php +++ b/tests/Sites/SitesConfigTest.php @@ -133,6 +133,7 @@ public function it_resolves_antlers_when_resolving_sites() ]); Config::set('statamic.some_addon.theme', 'sunset'); + Config::set('statamic.system.view_config_allowlist', ['@default', 'app.faker_locale', 'statamic.some_addon.theme']); Site::setSites([ 'default' => [ diff --git a/tests/View/CascadeTest.php b/tests/View/CascadeTest.php index 38da72797e5..e305ab38235 100644 --- a/tests/View/CascadeTest.php +++ b/tests/View/CascadeTest.php @@ -84,13 +84,60 @@ public function it_hydrates_constants() $this->assertEquals('', $cascade['xml_header']); $this->assertEquals(csrf_token(), $cascade['csrf_token']); $this->assertEquals(csrf_field(), $cascade['csrf_field']); - $this->assertEquals(config()->all(), $cascade['config']); + $this->assertEquals(Cascade::config(), $cascade['config']); // Response code is constant. It gets manually overridden on errors. $this->assertEquals(200, $cascade['response_code']); }); } + #[Test] + public function it_only_hydrates_allowlisted_config_values() + { + config([ + 'app.foo' => 'bar', + 'statamic.system.view_config_allowlist' => ['app.name'], + ]); + + tap($this->cascade()->hydrate()->toArray(), function ($cascade) { + $this->assertTrue(Arr::has($cascade['config'], 'app.name')); + $this->assertFalse(Arr::has($cascade['config'], 'app.foo')); + }); + } + + #[Test] + public function overriding_the_allowlist_changes_the_config_subset() + { + config(['statamic.system.view_config_allowlist' => ['app.name']]); + + $nameOnly = Cascade::config(); + + config(['statamic.system.view_config_allowlist' => ['app.env']]); + + $envOnly = Cascade::config(); + + $this->assertTrue(Arr::has($nameOnly, 'app.name')); + $this->assertFalse(Arr::has($nameOnly, 'app.env')); + $this->assertTrue(Arr::has($envOnly, 'app.env')); + $this->assertFalse(Arr::has($envOnly, 'app.name')); + } + + #[Test] + public function default_allowlist_can_be_extended_with_default_spread_syntax() + { + config([ + 'app.foo' => 'bar', + 'statamic.system.license_key' => 'test-license-key', + 'statamic.system.view_config_allowlist' => ['@default', 'app.foo'], + ]); + + $config = Cascade::config(); + + $this->assertTrue(Arr::has($config, 'app.name')); + $this->assertTrue(Arr::has($config, 'app.foo')); + $this->assertFalse(Arr::has($config, 'statamic.system.license_key')); + } + #[Test] public function it_hydrates_auth_when_logged_in() {