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/filters/SecFetchSiteFilter.php b/src/filters/SecFetchSiteFilter.php new file mode 100644 index 00000000000..fde065fde27 --- /dev/null +++ b/src/filters/SecFetchSiteFilter.php @@ -0,0 +1,74 @@ +setDefaults(); + + $request = Craft::$app->getRequest(); + + if (in_array($request->getMethod(), $this->safeMethods, 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; + } + + 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.'); + } +} 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 */ diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php new file mode 100644 index 00000000000..5b9e2a83060 --- /dev/null +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -0,0 +1,111 @@ + + * @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 testAllowsFallbackWhenHeaderMissingAndNotOriginOnly(): 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 testInvalidHeaderFallsThroughWhenNotOriginOnly(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->request->getHeaders()->set('Sec-Fetch-Site', 'cross-site'); + + $this->filter->originOnly = false; + self::assertTrue($this->filter->beforeAction($this->action)); + } +}