Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3c19b57
WIP changelog
brandonkelly Mar 3, 2026
a9f08a2
Merge branch '4.x' into 4.18
brandonkelly Mar 6, 2026
c12ffe6
Merge branch '4.x' into 4.18
brandonkelly Mar 6, 2026
8b8fef6
Merge branch '4.x' into 4.18
brandonkelly Mar 9, 2026
ffcceb1
Merge branch '4.x' into 4.18
brandonkelly Mar 15, 2026
dba7e48
Merge branch '4.x' into 4.18
brandonkelly Mar 27, 2026
482fa75
Add Sec-Fetch-Site action filter
timkelty Mar 29, 2026
f091048
Refine Sec-Fetch-Site filter defaults
timkelty Mar 29, 2026
0f320f0
Rename originOnly to strict
timkelty Mar 29, 2026
50bfb8a
Match Laravel option naming
timkelty Mar 29, 2026
93e437c
Fix stale SecFetchSiteFilter test names
timkelty Mar 29, 2026
cdce880
Merge branch '4.x' into 4.18
brandonkelly Mar 30, 2026
3aa07bc
Merge branch '4.18' into feature/sec-fetch-site-filter
brandonkelly Mar 30, 2026
7ec94eb
Release note
brandonkelly Mar 30, 2026
c843075
Merge pull request #18641 from craftcms/feature/sec-fetch-site-filter
brandonkelly Mar 30, 2026
508d7a7
Fixed tests
brandonkelly Apr 2, 2026
c704415
Enforce sync JS
timkelty Mar 13, 2026
f72f4f9
Preserve append HTML ordering
timkelty Mar 29, 2026
54ea8f3
Deprecate resource JS proxy
timkelty Mar 29, 2026
0b5ccc1
Remove resource JS proxy
timkelty Mar 29, 2026
8659df1
Restore controller and deprecate
timkelty Apr 14, 2026
f8007a9
Updated build CP
timkelty Apr 14, 2026
ac83cac
Merge branch '4.x' into 4.18
brandonkelly Apr 21, 2026
bd29a82
Merge branch '4.18' into bugfix/enforce-sync-js
brandonkelly Apr 21, 2026
a7c3c64
Go ahead and remove app/resource-js actually
brandonkelly Apr 21, 2026
a6b614d
Release notes
brandonkelly Apr 21, 2026
bfbb949
Merge pull request #18559 from craftcms/bugfix/enforce-sync-js
brandonkelly Apr 21, 2026
e71b063
Reference GHSA-c55v-343g-5xff
brandonkelly Apr 22, 2026
710b4a2
Merge branch '4.x' into 4.18
brandonkelly Apr 22, 2026
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
11 changes: 11 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 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))

### Extensibility
- Removed `craft\controllers\AppController::actionResourceJs()`. ([#18559](https://github.com/craftcms/cms/pull/18559))

### System
- Cross-domain script tags added by JavaScript are now loaded directly, rather than via a proxy. ([#18559](https://github.com/craftcms/cms/pull/18559))
- Fixed a [low-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) JavaScript injection vulnerability. (GHSA-c55v-343g-5xff)
49 changes: 0 additions & 49 deletions src/controllers/AppController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@
use craft\helpers\DateTimeHelper;
use craft\helpers\Html;
use craft\helpers\Json;
use craft\helpers\Path;
use craft\helpers\Session;
use craft\helpers\StringHelper;
use craft\helpers\Update as UpdateHelper;
use craft\helpers\UrlHelper;
use craft\models\Update;
Expand All @@ -34,7 +31,6 @@
use craft\web\ServiceUnavailableHttpException;
use DateInterval;
use Throwable;
use yii\base\InvalidArgumentException;
use yii\base\InvalidConfigException;
use yii\web\BadRequestHttpException;
use yii\web\Cookie;
Expand Down Expand Up @@ -103,51 +99,6 @@ public function actionHealthCheck(): Response
return $this->response;
}

/**
* Loads the given JavaScript resource URL and returns it.
*
* @param string $url
* @return Response
*/
public function actionResourceJs(string $url): Response
{
$this->requireCpRequest();

$assetManager = Craft::$app->getAssetManager();
$baseUrl = StringHelper::ensureRight($assetManager->baseUrl, '/');
if (!str_starts_with($url, $baseUrl)) {
throw new BadRequestHttpException("$url does not appear to be a resource URL");
}

$resourceUri = preg_replace('/^(.*)\?.*/', '$1', substr($url, strlen($baseUrl)));

if (!Path::ensurePathIsContained($resourceUri)) {
throw new BadRequestHttpException("Invalid resource: $resourceUri");
}

// If we aren’t caching source paths in the resourcepaths table,
// then we’re going to have to fetch the file over HTTP
if (!$assetManager->cacheSourcePaths) {
// Close the PHP session in case this takes a while
Session::close();

$response = Craft::createGuzzleClient()->get($url);
$this->response->setCacheHeaders();
$this->response->getHeaders()->set('content-type', 'application/javascript');
return $this->asRaw($response->getBody());
}

try {
$publishedPath = App::resourcePathByUri($resourceUri);
} catch (InvalidArgumentException $e) {
throw new BadRequestHttpException($e->getMessage(), previous: $e);
}

return $this->response->sendFile($publishedPath, null, [
'inline' => true,
]);
}

/**
* Returns the latest Craftnet API headers.
*
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.');
}
}
2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/cp.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/cp.js.map

Large diffs are not rendered by default.

99 changes: 67 additions & 32 deletions src/web/assets/cp/src/js/Craft.js
Original file line number Diff line number Diff line change
Expand Up @@ -1812,56 +1812,91 @@ $.extend(Craft, {

_existingCss: null,
_existingJs: null,
_appendHtmlQueue: 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, '&amp;'));
}

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, '&amp;'));
// 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, '&amp;'));
}
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);
}

// Load scripts sequentially via native <script> insertion to
// preserve execution order without requiring CORS.
const parentEl = $parent[0];
for (const scriptNode of scriptNodes) {
const scriptEl = document.createElement('script');

this._existingJs.push(node.src);
for (const attr of scriptNode.attributes) {
scriptEl.setAttribute(attr.name, attr.value);
}

return true;
});
if (scriptEl.src) {
await new Promise((resolve) => {
scriptEl.onload = scriptEl.onerror = resolve;
parentEl.appendChild(scriptEl);
});
} else {
scriptEl.textContent = scriptNode.textContent;
parentEl.appendChild(scriptEl);
}
}
},

$parent.append(nodes);
_queueAppendHtml: function (html, $parent) {
const append = () => this._appendHtml(html, $parent);
this._appendHtmlQueue = (this._appendHtmlQueue || Promise.resolve())
.catch(() => {})
.then(append);
return this._appendHtmlQueue;
},

/**
Expand All @@ -1870,8 +1905,8 @@ $.extend(Craft, {
* @param {string} html
* @returns {Promise}
*/
appendHeadHtml: async function (html) {
this._appendHtml(html, $('head'));
appendHeadHtml: function (html) {
return this._queueAppendHtml(html, $('head'));
},

/**
Expand All @@ -1880,8 +1915,8 @@ $.extend(Craft, {
* @param {string} html
* @returns {Promise}
*/
appendBodyHtml: async function (html) {
this._appendHtml(html, Garnish.$bod);
appendBodyHtml: function (html) {
return this._queueAppendHtml(html, Garnish.$bod);
},

/**
Expand All @@ -1893,7 +1928,7 @@ $.extend(Craft, {
console.warn(
'Craft.appendFootHtml() is deprecated. Craft.appendBodyHtml() should be used instead.'
);
this.appendBodyHtml(html);
return this.appendBodyHtml(html);
},

/**
Expand Down
Loading
Loading