From c9640cbc075def63a58255307579db4ea5209998 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Fri, 27 Mar 2026 11:08:34 -0700 Subject: [PATCH 1/6] Blocklist rather than allowlist for create() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-94rc-cqvm-m4pw isn’t really a concern anymore thanks to Twig sandboxing. Fully resolves #18376 --- CHANGELOG.md | 1 + src/web/twig/Extension.php | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8801ba689..903179f37dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Most classes can now be instantiated via the `create()` Twig function. ([#18376](https://github.com/craftcms/cms/discussions/18376)) - Fixed a bug where element search query caches weren’t getting invalidated when elements’ search keywords were indexed. ([#18275](https://github.com/craftcms/cms/issues/18275)) ## 4.17.12 - 2026-03-26 diff --git a/src/web/twig/Extension.php b/src/web/twig/Extension.php index 40140479513..d727bd69cb7 100644 --- a/src/web/twig/Extension.php +++ b/src/web/twig/Extension.php @@ -64,9 +64,13 @@ use DateTime; use DateTimeInterface; use DateTimeZone; +use DirectoryIterator; +use GuzzleHttp\Psr7\FnStream; use Illuminate\Support\Collection; use IteratorAggregate; use Money\Money; +use SimpleXMLElement; +use Symfony\Component\Process\Process; use Throwable; use Traversable; use Twig\DeprecatedCallableInfo; @@ -82,6 +86,7 @@ use yii\base\BaseObject; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; +use yii\behaviors\AttributeTypecastBehavior; use yii\db\Exception; use yii\db\Expression; use yii\db\QueryInterface; @@ -1495,11 +1500,22 @@ public function collectFunction(mixed $var): Collection public function createFunction(string|array $type, array $params = []): object { $class = is_string($type) ? $type : ($type['__class'] ?? $type['class'] ?? null); - if ( - !is_subclass_of($class, BaseObject::class) && - !str_starts_with($class, 'craft\\helpers\\') - ) { - throw new InvalidArgumentException(sprintf('create() can only be used to create instances of %s.', BaseObject::class)); + if (!$class) { + throw new InvalidArgumentException('No class specified for create().'); + } + + $blocklist = [ + AttributeTypecastBehavior::class, + DirectoryIterator::class, + FnStream::class, + Process::class, + SimpleXMLElement::class, + ]; + + foreach ($blocklist as $c) { + if (is_a($class, $c, true)) { + throw new InvalidArgumentException(sprintf('create() cannot be used to create instances of %s.', $class)); + } } /** @var BaseObject */ From 482fa75e8208bdb4880579c5095b403a4b6454cb Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 06:03:08 -0400 Subject: [PATCH 2/6] Add Sec-Fetch-Site action filter --- src/filters/SecFetchSiteFilter.php | 91 ++++++++++++++ tests/unit/filters/SecFetchSiteFilterTest.php | 113 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/filters/SecFetchSiteFilter.php create mode 100644 tests/unit/filters/SecFetchSiteFilterTest.php diff --git a/src/filters/SecFetchSiteFilter.php b/src/filters/SecFetchSiteFilter.php new file mode 100644 index 00000000000..1a8d9cab00a --- /dev/null +++ b/src/filters/SecFetchSiteFilter.php @@ -0,0 +1,91 @@ +enabled) { + return true; + } + + $request = Craft::$app->getRequest(); + + if (!in_array($request->getMethod(), $this->unsafeMethods, true)) { + return true; + } + + $secFetchSite = $request->getHeaders()->get($this->headerName); + + if ($secFetchSite === 'same-origin') { + return true; + } + + if ($secFetchSite === 'same-site' && $this->allowSameSite) { + return true; + } + + if ($this->originOnly) { + throw new BadRequestHttpException($this->errorMessage); + } + + return true; + } +} diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php new file mode 100644 index 00000000000..0a15a2ab789 --- /dev/null +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -0,0 +1,113 @@ + + * @since 4.18.0 + */ +class SecFetchSiteFilterTest extends TestCase +{ + private SecFetchSiteFilter $filter; + private Action $action; + private Request $request; + + protected function setUp(): void + { + parent::setUp(); + + $controller = $this->createMock(Controller::class); + $this->action = new Action('test-action', $controller); + $this->filter = new SecFetchSiteFilter(); + $this->request = Craft::$app->getRequest(); + } + + public function testAllowsSameOriginForUnsafeMethods(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'same-origin'); + + self::assertTrue($this->filter->beforeAction($this->action)); + } + + public function testAllowsSameSiteWhenConfigured(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'same-site'); + + $this->filter->allowSameSite = true; + self::assertTrue($this->filter->beforeAction($this->action)); + } + + public function testAllowsFallbackWhenHeaderMissingAndOriginOnlyDisabled(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->remove('Sec-Fetch-Site'); + + $this->filter->originOnly = false; + self::assertTrue($this->filter->beforeAction($this->action)); + } + + public function testRejectsWhenOriginOnlyAndHeaderInvalid(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); + + $this->filter->originOnly = true; + + $this->expectException(BadRequestHttpException::class); + $this->filter->beforeAction($this->action); + } + + public function testEnforcesWhenCsrfDisabled(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); + + $original = $this->request->enableCsrfValidation; + $this->request->enableCsrfValidation = false; + + try { + $this->filter->originOnly = true; + $this->expectException(BadRequestHttpException::class); + $this->filter->beforeAction($this->action); + } finally { + $this->request->enableCsrfValidation = $original; + } + } + + public function testSkipsSafeMethods(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); + + $this->filter->originOnly = true; + self::assertTrue($this->filter->beforeAction($this->action)); + } + + public function testDisabledFilterSkipsValidation(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); + + $this->filter->enabled = false; + $this->filter->originOnly = true; + + self::assertTrue($this->filter->beforeAction($this->action)); + } +} From f0910489f15403112c4ea5305b0a11d1fc1977de Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 06:37:41 -0400 Subject: [PATCH 3/6] Refine Sec-Fetch-Site filter defaults --- src/filters/SecFetchSiteFilter.php | 43 +++++++----------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/src/filters/SecFetchSiteFilter.php b/src/filters/SecFetchSiteFilter.php index 1a8d9cab00a..c7162b7f056 100644 --- a/src/filters/SecFetchSiteFilter.php +++ b/src/filters/SecFetchSiteFilter.php @@ -14,61 +14,32 @@ /** * Action filter for validating the `Sec-Fetch-Site` header. * - * When enabled, requests with `Sec-Fetch-Site: same-origin` (or `same-site` when allowed) - * will pass immediately without requiring a CSRF token. If the header is missing or invalid, - * validation falls back to the CSRF token unless `originOnly` is enabled. - * - * This filter enforces the header regardless of the global CSRF setting; disable the filter - * or add `except` rules to allow non-browser clients. - * * @since 4.18.0 */ class SecFetchSiteFilter extends ActionFilter { use ConditionalFilterTrait; - /** - * @var bool Whether the filter is enabled. - */ - public bool $enabled = true; - - /** - * @var bool Whether to require a valid `Sec-Fetch-Site` header with no CSRF token fallback. - */ public bool $originOnly = true; - /** - * @var bool Whether to accept `same-site` in addition to `same-origin`. - */ public bool $allowSameSite = false; - /** - * @var string The header name to check. - */ public string $headerName = 'Sec-Fetch-Site'; - /** - * @var string The error message for rejected requests. - */ - public string $errorMessage = 'Unable to verify your data submission.'; + public ?string $errorMessage = null; - /** - * @var string[] The HTTP methods that should be checked. - */ - public array $unsafeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; + public ?array $safeMethods = null; /** * @inheritdoc */ public function beforeAction($action): bool { - if (!$this->enabled) { - return true; - } + $this->setDefaults(); $request = Craft::$app->getRequest(); - if (!in_array($request->getMethod(), $this->unsafeMethods, true)) { + if (in_array($request->getMethod(), $this->safeMethods, true)) { return true; } @@ -88,4 +59,10 @@ public function beforeAction($action): bool return true; } + + private function setDefaults(): void + { + $this->safeMethods = $this->safeMethods ?? Craft::$app->getRequest()->csrfTokenSafeMethods; + $this->errorMessage = $this->errorMessage ?? Craft::t('yii', 'Unable to verify your data submission.'); + } } From 0f320f0ebdedbfd654a1d6e739694347ccd1ad22 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 06:54:13 -0400 Subject: [PATCH 4/6] Rename originOnly to strict --- src/filters/SecFetchSiteFilter.php | 10 ++++++++-- tests/unit/filters/SecFetchSiteFilterTest.php | 18 ++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/filters/SecFetchSiteFilter.php b/src/filters/SecFetchSiteFilter.php index c7162b7f056..c69dc295cac 100644 --- a/src/filters/SecFetchSiteFilter.php +++ b/src/filters/SecFetchSiteFilter.php @@ -20,8 +20,14 @@ class SecFetchSiteFilter extends ActionFilter { use ConditionalFilterTrait; - public bool $originOnly = true; + /** + * Whether to require a valid `Sec-Fetch-Site` header (no CSRF token fallback). + */ + public bool $strict = true; + /** + * Whether to accept `same-site` in addition to `same-origin` (e.g. subdomains). + */ public bool $allowSameSite = false; public string $headerName = 'Sec-Fetch-Site'; @@ -53,7 +59,7 @@ public function beforeAction($action): bool return true; } - if ($this->originOnly) { + if ($this->strict) { throw new BadRequestHttpException($this->errorMessage); } diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php index 0a15a2ab789..e3ac197dfdf 100644 --- a/tests/unit/filters/SecFetchSiteFilterTest.php +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -54,21 +54,21 @@ public function testAllowsSameSiteWhenConfigured(): void self::assertTrue($this->filter->beforeAction($this->action)); } - public function testAllowsFallbackWhenHeaderMissingAndOriginOnlyDisabled(): void + public function testAllowsFallbackWhenHeaderMissingAndNotStrict(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->remove('Sec-Fetch-Site'); - $this->filter->originOnly = false; + $this->filter->strict = false; self::assertTrue($this->filter->beforeAction($this->action)); } - public function testRejectsWhenOriginOnlyAndHeaderInvalid(): void + public function testRejectsWhenStrictAndHeaderInvalid(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->originOnly = true; + $this->filter->strict = true; $this->expectException(BadRequestHttpException::class); $this->filter->beforeAction($this->action); @@ -83,7 +83,7 @@ public function testEnforcesWhenCsrfDisabled(): void $this->request->enableCsrfValidation = false; try { - $this->filter->originOnly = true; + $this->filter->strict = true; $this->expectException(BadRequestHttpException::class); $this->filter->beforeAction($this->action); } finally { @@ -96,18 +96,16 @@ public function testSkipsSafeMethods(): void $_SERVER['REQUEST_METHOD'] = 'GET'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->originOnly = true; + $this->filter->strict = true; self::assertTrue($this->filter->beforeAction($this->action)); } - public function testDisabledFilterSkipsValidation(): void + public function testInvalidHeaderFallsThroughWhenNotStrict(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->enabled = false; - $this->filter->originOnly = true; - + $this->filter->strict = false; self::assertTrue($this->filter->beforeAction($this->action)); } } From 50bfb8ae2c5083471f4a726bff774a807e06ab93 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 14:35:57 -0400 Subject: [PATCH 5/6] Match Laravel option naming --- src/filters/SecFetchSiteFilter.php | 6 +++--- tests/unit/filters/SecFetchSiteFilterTest.php | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/filters/SecFetchSiteFilter.php b/src/filters/SecFetchSiteFilter.php index c69dc295cac..fde065fde27 100644 --- a/src/filters/SecFetchSiteFilter.php +++ b/src/filters/SecFetchSiteFilter.php @@ -21,9 +21,9 @@ class SecFetchSiteFilter extends ActionFilter use ConditionalFilterTrait; /** - * Whether to require a valid `Sec-Fetch-Site` header (no CSRF token fallback). + * Whether to use origin verification only (no CSRF token fallback). */ - public bool $strict = true; + public bool $originOnly = true; /** * Whether to accept `same-site` in addition to `same-origin` (e.g. subdomains). @@ -59,7 +59,7 @@ public function beforeAction($action): bool return true; } - if ($this->strict) { + if ($this->originOnly) { throw new BadRequestHttpException($this->errorMessage); } diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php index e3ac197dfdf..1ffa29df04a 100644 --- a/tests/unit/filters/SecFetchSiteFilterTest.php +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -59,7 +59,7 @@ public function testAllowsFallbackWhenHeaderMissingAndNotStrict(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->remove('Sec-Fetch-Site'); - $this->filter->strict = false; + $this->filter->originOnly = false; self::assertTrue($this->filter->beforeAction($this->action)); } @@ -68,7 +68,7 @@ public function testRejectsWhenStrictAndHeaderInvalid(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->strict = true; + $this->filter->originOnly = true; $this->expectException(BadRequestHttpException::class); $this->filter->beforeAction($this->action); @@ -83,7 +83,7 @@ public function testEnforcesWhenCsrfDisabled(): void $this->request->enableCsrfValidation = false; try { - $this->filter->strict = true; + $this->filter->originOnly = true; $this->expectException(BadRequestHttpException::class); $this->filter->beforeAction($this->action); } finally { @@ -96,7 +96,7 @@ public function testSkipsSafeMethods(): void $_SERVER['REQUEST_METHOD'] = 'GET'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->strict = true; + $this->filter->originOnly = true; self::assertTrue($this->filter->beforeAction($this->action)); } @@ -105,7 +105,7 @@ public function testInvalidHeaderFallsThroughWhenNotStrict(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); - $this->filter->strict = false; + $this->filter->originOnly = false; self::assertTrue($this->filter->beforeAction($this->action)); } } From 93e437ceabd7e9b907dc0877d6977c5422238369 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 14:39:34 -0400 Subject: [PATCH 6/6] Fix stale SecFetchSiteFilter test names --- tests/unit/filters/SecFetchSiteFilterTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php index 1ffa29df04a..5b9e2a83060 100644 --- a/tests/unit/filters/SecFetchSiteFilterTest.php +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -54,7 +54,7 @@ public function testAllowsSameSiteWhenConfigured(): void self::assertTrue($this->filter->beforeAction($this->action)); } - public function testAllowsFallbackWhenHeaderMissingAndNotStrict(): void + public function testAllowsFallbackWhenHeaderMissingAndNotOriginOnly(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->remove('Sec-Fetch-Site'); @@ -63,7 +63,7 @@ public function testAllowsFallbackWhenHeaderMissingAndNotStrict(): void self::assertTrue($this->filter->beforeAction($this->action)); } - public function testRejectsWhenStrictAndHeaderInvalid(): void + public function testRejectsWhenOriginOnlyAndHeaderInvalid(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); @@ -100,7 +100,7 @@ public function testSkipsSafeMethods(): void self::assertTrue($this->filter->beforeAction($this->action)); } - public function testInvalidHeaderFallsThroughWhenNotStrict(): void + public function testInvalidHeaderFallsThroughWhenNotOriginOnly(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site');