From 3c19b576d48164d7775a37464e373ee14b9c48a1 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 3 Mar 2026 06:54:26 -0800 Subject: [PATCH 01/18] WIP changelog --- CHANGELOG-WIP.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CHANGELOG-WIP.md diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000000..4fb1dbcb123 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1 @@ +# Release Notes for Craft CMS 4.18 (WIP) From 482fa75e8208bdb4880579c5095b403a4b6454cb Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 29 Mar 2026 06:03:08 -0400 Subject: [PATCH 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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'); From 7ec94eb79d9bf789ac2a1c63841bb2815c3c1bcc Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 30 Mar 2026 12:37:08 -0700 Subject: [PATCH 07/18] Release note [ci skip] --- CHANGELOG-WIP.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 4fb1dbcb123..cc39f9d95b7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1 +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)) From 508d7a7c19c2c39ae77af928fdc6be4d8d34dc5b Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 2 Apr 2026 10:03:43 -0700 Subject: [PATCH 08/18] Fixed tests --- tests/unit/filters/SecFetchSiteFilterTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/filters/SecFetchSiteFilterTest.php b/tests/unit/filters/SecFetchSiteFilterTest.php index 5b9e2a83060..24de1cafbf0 100644 --- a/tests/unit/filters/SecFetchSiteFilterTest.php +++ b/tests/unit/filters/SecFetchSiteFilterTest.php @@ -43,6 +43,8 @@ public function testAllowsSameOriginForUnsafeMethods(): void $this->request->getHeaders()->set('Sec-Fetch-Site', 'same-origin'); self::assertTrue($this->filter->beforeAction($this->action)); + + unset($_SERVER['REQUEST_METHOD']); } public function testAllowsSameSiteWhenConfigured(): void @@ -52,6 +54,8 @@ public function testAllowsSameSiteWhenConfigured(): void $this->filter->allowSameSite = true; self::assertTrue($this->filter->beforeAction($this->action)); + + unset($_SERVER['REQUEST_METHOD']); } public function testAllowsFallbackWhenHeaderMissingAndNotOriginOnly(): void @@ -61,6 +65,8 @@ public function testAllowsFallbackWhenHeaderMissingAndNotOriginOnly(): void $this->filter->originOnly = false; self::assertTrue($this->filter->beforeAction($this->action)); + + unset($_SERVER['REQUEST_METHOD']); } public function testRejectsWhenOriginOnlyAndHeaderInvalid(): void @@ -72,6 +78,8 @@ public function testRejectsWhenOriginOnlyAndHeaderInvalid(): void $this->expectException(BadRequestHttpException::class); $this->filter->beforeAction($this->action); + + unset($_SERVER['REQUEST_METHOD']); } public function testEnforcesWhenCsrfDisabled(): void @@ -89,6 +97,8 @@ public function testEnforcesWhenCsrfDisabled(): void } finally { $this->request->enableCsrfValidation = $original; } + + unset($_SERVER['REQUEST_METHOD']); } public function testSkipsSafeMethods(): void @@ -98,6 +108,8 @@ public function testSkipsSafeMethods(): void $this->filter->originOnly = true; self::assertTrue($this->filter->beforeAction($this->action)); + + unset($_SERVER['REQUEST_METHOD']); } public function testInvalidHeaderFallsThroughWhenNotOriginOnly(): void @@ -107,5 +119,7 @@ public function testInvalidHeaderFallsThroughWhenNotOriginOnly(): void $this->filter->originOnly = false; self::assertTrue($this->filter->beforeAction($this->action)); + + unset($_SERVER['REQUEST_METHOD']); } } From c704415500539f83ce5426bf96a23b1810da82f4 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 13 Mar 2026 14:36:40 -0400 Subject: [PATCH 09/18] Enforce sync JS --- src/web/assets/cp/src/js/Craft.js | 89 ++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/src/web/assets/cp/src/js/Craft.js b/src/web/assets/cp/src/js/Craft.js index 595609e5bdd..39208e7793c 100644 --- a/src/web/assets/cp/src/js/Craft.js +++ b/src/web/assets/cp/src/js/Craft.js @@ -1813,55 +1813,82 @@ $.extend(Craft, { _existingCss: null, _existingJs: null, - _appendHtml: function (html, $parent) { + _appendHtml: async function (html, $parent) { if (!html) { return; } - const nodes = $.parseHTML(html.trim(), true).filter((node) => { + /** + * Separate scripts from other nodes to bypass jQuery's internal + * script handling, which uses sync XHR (jQuery._evalUrl) and + * silently falls back to async for cross-origin URLs, breaking + * execution order. + * + * @see https://github.com/jquery/jquery/issues/4801 + * @see https://github.com/jquery/jquery/issues/1895 + */ + const scriptNodes = []; + const otherNodes = []; + + for (const node of $.parseHTML(html.trim(), true)) { + + // Deduplicate CSS if (node.nodeName === 'LINK' && node.href) { if (!this._existingCss) { this._existingCss = $('link[href]') .toArray() .map((n) => n.href.replace(/&/g, '&')); } - if (this._existingCss.includes(node.href)) { - return false; + continue; } - this._existingCss.push(node.href); - return true; } - if (node.nodeName === 'SCRIPT' && node.src) { - if (!this._existingJs) { - this._existingJs = $('script[src]') - .toArray() - .map((n) => n.src.replace(/&/g, '&')); + // Deduplicate and separate scripts + if (node.nodeName === 'SCRIPT') { + if (node.src) { + if (!this._existingJs) { + this._existingJs = $('script[src]') + .toArray() + .map((n) => n.src.replace(/&/g, '&')); + } + if (this._existingJs.includes(node.src)) { + continue; + } + this._existingJs.push(node.src); } + scriptNodes.push(node); + continue; + } - // if this is a cross-domain JS resource, use our app/resource-js proxy to load it - if ( - node.src.startsWith(this.resourceBaseUrl) && - !this.isSameHost(node.src) - ) { - node.src = this.getActionUrl('app/resource-js', { - url: node.src, - }); - } + otherNodes.push(node); + } - if (this._existingJs.includes(node.src)) { - return false; - } + if (otherNodes.length) { + $parent.append(otherNodes); + } - this._existingJs.push(node.src); - } + // Load scripts sequentially via native ';\n\n // Create a new iframe\n var $iframe = $('