diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000000..cc39f9d95b7 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,4 @@ +# Release Notes for Craft CMS 4.18 (WIP) + +### Development +- Added `craft\filters\SecFetchSiteFilter` for request origin verification. ([#18641](https://github.com/craftcms/cms/pull/18641)) 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/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php new file mode 100644 index 00000000000..24de1cafbf0 --- /dev/null +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -0,0 +1,125 @@ + + * @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)); + + unset($_SERVER['REQUEST_METHOD']); + } + + 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)); + + unset($_SERVER['REQUEST_METHOD']); + } + + 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)); + + unset($_SERVER['REQUEST_METHOD']); + } + + 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); + + unset($_SERVER['REQUEST_METHOD']); + } + + 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; + } + + unset($_SERVER['REQUEST_METHOD']); + } + + 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)); + + unset($_SERVER['REQUEST_METHOD']); + } + + 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)); + + unset($_SERVER['REQUEST_METHOD']); + } +}