Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions src/filters/SecFetchSiteFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\filters;

use Craft;
use yii\base\ActionFilter;
use yii\web\BadRequestHttpException;

/**
* Action filter for validating the `Sec-Fetch-Site` header.
*
* @since 4.18.0
*/
class SecFetchSiteFilter extends ActionFilter
{
use ConditionalFilterTrait;

/**
* Whether to use origin verification only (no CSRF token fallback).
*/
public bool $originOnly = true;

/**
* Whether to accept `same-site` in addition to `same-origin` (e.g. subdomains).
*/
public bool $allowSameSite = false;

public string $headerName = 'Sec-Fetch-Site';

public ?string $errorMessage = null;

public ?array $safeMethods = null;

/**
* @inheritdoc
*/
public function beforeAction($action): bool
{
$this->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.');
}
}
26 changes: 21 additions & 5 deletions src/web/twig/Extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/filters/SecFetchSiteFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace crafttests\unit\filters;

use Craft;
use craft\filters\SecFetchSiteFilter;
use craft\test\TestCase;
use craft\web\Request;
use yii\base\Action;
use yii\web\BadRequestHttpException;
use yii\web\Controller;

/**
* Unit tests for SecFetchSiteFilter
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @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));
}
}
Loading