From eeb386d1d2809cf79e01a545d7e186646ef4604b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dise=C3=B1o=20y=20Software=20Windsock=20S=2EL=2E?= <127088956+david-windsock@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:47:32 +0200 Subject: [PATCH 1/4] [5.x] Hash URL in static caching lock key (#15061) --- src/StaticCaching/Middleware/Cache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StaticCaching/Middleware/Cache.php b/src/StaticCaching/Middleware/Cache.php index f80342957c..5d66320c26 100644 --- a/src/StaticCaching/Middleware/Cache.php +++ b/src/StaticCaching/Middleware/Cache.php @@ -215,7 +215,7 @@ private function createLock($request): Lock $store = AppCache::store('null'); } else { $store = StaticCache::cacheStore(); - $key .= '-'.$this->cacher->getUrl($request); + $key .= '-'.md5($this->cacher->getUrl($request)); } return $store->lock($key, $this->lockFor); From a17025f88c3ee46f8e7e4eb511dc33e8476092d4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 7 Aug 2026 17:18:51 -0400 Subject: [PATCH 2/4] [5.x] Tighten get modifier (#15138) Co-authored-by: Claude Opus 5 --- src/Modifiers/CoreModifiers.php | 7 ----- tests/Modifiers/GetTest.php | 48 +++------------------------------ 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index 3ef3a80937..4f9b0fc806 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -30,7 +30,6 @@ use Statamic\Support\Arr; use Statamic\Support\Dumper; use Statamic\Support\Html; -use Statamic\Support\MethodDenylist; use Statamic\Support\Str; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Stringy\StaticStringy as Stringy; @@ -867,12 +866,6 @@ public function get($value, $params) return Arr::get($array, $var); } - // Finally, try to call a method on the object - $method = Str::slug($var); - if (method_exists($item, $method) && ! MethodDenylist::blocks($method)) { - return $item->$method(); - } - // If after all is said and done, there's still nothing, just show the original value. return $value; } diff --git a/tests/Modifiers/GetTest.php b/tests/Modifiers/GetTest.php index 60a753c434..d8e0402261 100644 --- a/tests/Modifiers/GetTest.php +++ b/tests/Modifiers/GetTest.php @@ -22,7 +22,7 @@ public function it_gets_a_field_value() } #[Test] - public function it_returns_falsy_field_values_instead_of_falling_through_to_a_method() + public function it_returns_falsy_field_values_instead_of_the_original_value() { $item = new GetTestItem; @@ -32,45 +32,20 @@ public function it_returns_falsy_field_values_instead_of_falling_through_to_a_me } #[Test] - public function it_dispatches_to_a_non_destructive_accessor_method() + public function it_does_not_dispatch_to_methods() { $item = new GetTestItem; - $this->assertEquals('https://example.com', $this->modify($item, 'url')); - } - - #[Test] - public function it_does_not_dispatch_to_destructive_methods() - { - $item = new GetTestItem; - - $result = $this->modify($item, 'delete'); + $this->assertSame($item, $this->modify($item, 'url')); + $this->assertSame($item, $this->modify($item, 'delete')); $this->assertFalse($item->deleted); - $this->assertSame($item, $result); - } - - #[Test] - public function it_does_not_dispatch_to_destructive_methods_case_insensitively() - { - // Str::slug() lowercases the parameter (e.g. "deleteQuietly" => "deletequietly"), - // but method_exists() is case-insensitive, so the denylist must match regardless of case. - $item = new GetTestItem; - - $this->modify($item, 'deletequietly'); - $this->assertFalse($item->deletedQuietly); - - $this->modify($item, 'savequietly'); - $this->assertFalse($item->savedQuietly); } } class GetTestItem { public $deleted = false; - public $deletedQuietly = false; - public $saved = false; - public $savedQuietly = false; public function toArray() { @@ -91,19 +66,4 @@ public function delete() { $this->deleted = true; } - - public function deleteQuietly() - { - $this->deletedQuietly = true; - } - - public function save() - { - $this->saved = true; - } - - public function saveQuietly() - { - $this->savedQuietly = true; - } } From 57b589e43b959ca2cf1e9ec80007d8c9126a5d85 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 7 Aug 2026 18:49:32 -0400 Subject: [PATCH 3/4] [5.x] Harden SVG sanitization (#15137) Co-authored-by: Claude Opus 5 --- src/Assets/Uploader.php | 2 +- tests/Assets/AssetTest.php | 45 +++++++++++++++++++++++++- tests/Feature/Fieldtypes/FilesTest.php | 45 +++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/Assets/Uploader.php b/src/Assets/Uploader.php index c7c1d2ac12..f53718f759 100644 --- a/src/Assets/Uploader.php +++ b/src/Assets/Uploader.php @@ -58,7 +58,7 @@ private function write($sourcePath, $destinationPath) { $stream = fopen($sourcePath, 'r'); - if (config('statamic.assets.svg_sanitization_on_upload', true) && Str::endsWith($destinationPath, '.svg')) { + if (config('statamic.assets.svg_sanitization_on_upload', true) && trim(strtolower(pathinfo($destinationPath, PATHINFO_EXTENSION))) === 'svg') { $sanitizer = new DOMSanitizer(DOMSanitizer::SVG); $stream = $sanitizer->sanitize($svg = stream_get_contents($stream), [ 'remove-xml-tags' => ! Str::startsWith($svg, 'assertStringContainsString('', $asset->contents()); } + #[Test] + #[DataProvider('unnormalizedSvgExtensionProvider')] + public function it_sanitizes_svgs_on_upload_regardless_of_how_the_extension_is_written($extension) + { + if (trim($extension) !== $extension) { + $this->markTestSkippedInWindows('Windows does not allow filenames with trailing whitespace.'); + } + + Event::fake(); + + // Disable filename lowercasing so the uppercase extension actually + // reaches the disk, otherwise it'd be normalized before we could + // prove the sanitization check itself is case insensitive. + config()->set('statamic.assets.lowercase', false); + + $asset = (new Asset)->container($this->container)->path($path = "path/to/asset.{$extension}")->syncOriginal(); + + Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); + Storage::disk('test')->assertMissing($path); + + $return = $asset->upload(UploadedFile::fake()->createWithContent("asset.{$extension}", '')); + + $this->assertEquals($asset, $return); + Storage::disk('test')->assertExists($path); + $this->assertEquals($path, $asset->path()); + + // Ensure the inline scripts were stripped out. + $this->assertStringNotContainsString('contents()); + $this->assertStringNotContainsString('Bad stuff could go in here.', $asset->contents()); + $this->assertStringNotContainsString('', $asset->contents()); + } + + public static function unnormalizedSvgExtensionProvider() + { + return [ + 'uppercase' => ['SVG'], + 'mixed case' => ['Svg'], + 'trailing whitespace' => ['svg '], + 'uppercase with trailing whitespace' => ['SVG '], + ]; + } + public static function nonGlideableFileExtensionsProvider() { return [ diff --git a/tests/Feature/Fieldtypes/FilesTest.php b/tests/Feature/Fieldtypes/FilesTest.php index 38e9ce2fdb..edb601e340 100644 --- a/tests/Feature/Fieldtypes/FilesTest.php +++ b/tests/Feature/Fieldtypes/FilesTest.php @@ -11,10 +11,11 @@ use Statamic\Facades\User; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; +use Tests\WindowsHelpers; class FilesTest extends TestCase { - use PreventSavingStacheItemsToDisk; + use PreventSavingStacheItemsToDisk, WindowsHelpers; public function setUp(): void { @@ -76,6 +77,48 @@ public function it_uploads_a_file($container, $isImage, $expectedPath, $expected } } + #[Test] + #[DataProvider('unnormalizedSvgExtensionProvider')] + public function it_sanitizes_svgs_on_upload_regardless_of_how_the_extension_is_written($extension) + { + if (trim($extension) !== $extension) { + $this->markTestSkippedInWindows('Windows does not allow filenames with trailing whitespace.'); + } + + Date::setTestNow(Date::createFromTimestamp(1671484636, config('app.timezone'))); + + $disk = Storage::fake('local'); + + $file = UploadedFile::fake()->createWithContent("test.{$extension}", ''); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->post('/cp/fieldtypes/files/upload', ['file' => $file]) + ->assertOk() + ->assertJson([ + 'data' => [ + 'id' => $path = "1671484636/test.{$extension}", + ], + ]); + + $contents = $disk->get('statamic/file-uploads/'.$path); + + // Ensure the inline scripts were stripped out. + $this->assertStringNotContainsString('assertStringNotContainsString('Bad stuff could go in here.', $contents); + $this->assertStringNotContainsString('', $contents); + } + + public static function unnormalizedSvgExtensionProvider() + { + return [ + 'uppercase' => ['SVG'], + 'mixed case' => ['Svg'], + 'trailing whitespace' => ['svg '], + 'uppercase with trailing whitespace' => ['SVG '], + ]; + } + public static function uploadProvider() { return [ From 4dd0b5c0faa262180ace188b8e55b8eb9902885c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 10 Aug 2026 18:23:47 -0400 Subject: [PATCH 4/4] [5.x] Tighten Antlers variable path resolution (#15154) Co-authored-by: Claude Opus 5 --- .../Language/Runtime/PathDataManager.php | 8 +-- .../Fixtures/MethodClasses/CallCounter.php | 7 +++ tests/Antlers/Runtime/MethodCallTest.php | 54 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 967d72ef6f..1d50a8e1cc 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -861,9 +861,11 @@ private function reduceVar($path, $processorData = []) } if (is_object($this->reducedVar) && method_exists($this->reducedVar, $method = Str::camel($varPath))) { - if (MethodDenylist::blocks($method)) { - // The method name derives from user-influenceable data, so never - // dispatch to methods that mutate or destroy data. Resolve to null. + // The method name derives from user-influenceable data, so never dispatch to + // methods that mutate or destroy data. Writing `{{ object.method }}` without + // parentheses calls the method just like `{{ object:method() }}` does, so both + // forms honor the `statamic.antlers.allowMethodsInContent` setting. + if (MethodDenylist::blocks($method) || (GlobalRuntimeState::$isEvaluatingUserData && ! GlobalRuntimeState::$allowMethodsInContent)) { $this->reducedVar = null; $this->didFind = false; $this->doBreak = true; diff --git a/tests/Antlers/Fixtures/MethodClasses/CallCounter.php b/tests/Antlers/Fixtures/MethodClasses/CallCounter.php index 41f051412f..77ef494b48 100644 --- a/tests/Antlers/Fixtures/MethodClasses/CallCounter.php +++ b/tests/Antlers/Fixtures/MethodClasses/CallCounter.php @@ -13,6 +13,13 @@ public function increment() return $this; } + public function incrementTwice() + { + $this->count += 2; + + return $this; + } + public function __toString(): string { return 'Count: '.$this->count; diff --git a/tests/Antlers/Runtime/MethodCallTest.php b/tests/Antlers/Runtime/MethodCallTest.php index d7f4782f13..a240e43215 100644 --- a/tests/Antlers/Runtime/MethodCallTest.php +++ b/tests/Antlers/Runtime/MethodCallTest.php @@ -343,6 +343,60 @@ public function test_method_calls_still_work_in_templates() ], false, true)); } + public function test_implicit_method_calls_blocked_in_user_content() + { + $counter = new CallCounter(); + + // Resolving a variable path to a zero-argument method is still a method + // call, so it honors the same setting as the explicit `:method()` syntax. + $this->assertSame('', $this->renderUserContent('{{ counter.increment }}', ['counter' => $counter])); + $this->assertSame('', $this->renderUserContent('{{ counter:increment }}', ['counter' => $counter])); + + // Str::camel() maps a snake_case path onto a camelCase method. + $this->assertSame('', $this->renderUserContent('{{ counter.increment_twice }}', ['counter' => $counter])); + + $this->assertSame('Count: 0', (string) $counter); + } + + public function test_implicit_method_calls_allowed_in_user_content_when_configured() + { + GlobalRuntimeState::$allowMethodsInContent = true; + + $object = new StringLengthObject('Hello'); + + $this->assertSame('5', $this->renderUserContent('{{ object.length }}', ['object' => $object])); + + GlobalRuntimeState::$allowMethodsInContent = false; + } + + public function test_implicit_method_calls_still_work_in_templates() + { + $object = new StringLengthObject('Hello'); + + $this->assertSame('5', $this->renderString('{{ object.length }}', [ + 'object' => $object, + ], false, true)); + + $this->assertSame('5', $this->renderString('{{ object:length }}', [ + 'object' => $object, + ], false, true)); + } + + private function renderUserContent($content, $data) + { + $textFieldtype = new Text(); + $field = new Field('text_field', [ + 'type' => 'text', + 'antlers' => true, + ]); + + $textFieldtype->setField($field); + + return $this->renderString('{{ text_field }}', array_merge($data, [ + 'text_field' => new Value($content, 'text_field', $textFieldtype), + ]), false, true); + } + public function test_nested_value_does_not_reset_user_data_flag() { $textFieldtype = new Text();