CVE-2026-41130
Description
Craft CMS is a content management system (CMS). In versions on the 4.x branch through 4.17.8 and the 5.x branch through 5.9.14, the resource-js endpoint in Craft CMS allows unauthenticated requests to proxy remote JavaScript resources. When trustedHosts is not explicitly restricted (default configuration), the application trusts the client-supplied Host header. This allows an attacker to control the derived baseUrl, which is used in prefix validation inside actionResourceJs(). By supplying a malicious Host header, the attacker can make the server issue arbitrary HTTP requests, leading to Server-Side Request Forgery (SSRF). Versions 4.17.9 and 5.9.15 patch the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
craftcms/cmsPackagist | >= 5.0.0-RC1, < 5.9.15 | 5.9.15 |
craftcms/cmsPackagist | >= 4.0.0-RC1, < 4.17.9 | 4.17.9 |
Affected products
1Patches
14 files changed · +89 −42
CHANGELOG.md+2 −1 modified@@ -2,13 +2,14 @@ ## Unreleased +- Added `craft\helpers\App::resourcePathByUri()`. - Fixed a bug where global set GraphQL query caches weren’t getting invalidated when global sets were updated. ([#18479](https://github.com/craftcms/cms/issues/18479)) - Fixed a bug where `users/suspend-user` and `users/unsuspend-user` actions required that the logged-in user have control panel access. ([#18485](https://github.com/craftcms/cms/issues/18485)) - Fixed a bug where flipping an image within the Image Editor didn’t always work. ([#18486](https://github.com/craftcms/cms/issues/18486)) - Fixed a bug where SVG files missing their `width` and `height` attributes weren’t getting them set as expected. - Fixed an error that occurred if a template referenced a preloaded Single entry followed by a null coalescing operator. ([#18503](https://github.com/craftcms/cms/issues/18503)) - Fixed a bug where links within Redactor fields were getting `target="_blank"` added to them. ([#18500](https://github.com/craftcms/cms/issues/18500)) -- Fixed a [moderate-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) SSRF vulnerability. (GHSA-3m9m-24vh-39wx) +- Fixed [moderate-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) SSRF vulnerabilities. (GHSA-3m9m-24vh-39wx, GHSA-95wr-3f2v-v2wh) ## 4.17.8 - 2026-02-25
src/controllers/AppController.php+15 −7 modified@@ -23,6 +23,7 @@ use craft\helpers\Html; use craft\helpers\Json; use craft\helpers\Session; +use craft\helpers\StringHelper; use craft\helpers\Update as UpdateHelper; use craft\helpers\UrlHelper; use craft\models\Update; @@ -32,6 +33,7 @@ use craft\web\ServiceUnavailableHttpException; use DateInterval; use Throwable; +use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; use yii\web\BadRequestHttpException; use yii\web\Cookie; @@ -110,17 +112,23 @@ public function actionResourceJs(string $url): Response { $this->requireCpRequest(); - if (!str_starts_with($url, Craft::$app->getAssetManager()->baseUrl)) { + $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"); } - // Close the PHP session in case this takes a while - Session::close(); + $resourceUri = preg_replace('/^(.*)\?.*/', '$1', substr($url, strlen($baseUrl))); - $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, + ]); } /**
src/helpers/App.php+68 −0 modified@@ -15,6 +15,8 @@ use craft\db\Connection; use craft\db\mysql\Schema as MysqlSchema; use craft\db\pgsql\Schema as PgsqlSchema; +use craft\db\Query; +use craft\db\Table; use craft\elements\User; use craft\enums\LicenseKeyStatus; use craft\errors\InvalidPluginException; @@ -41,6 +43,7 @@ use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\InvalidValueException; +use yii\db\Exception as DbException; use yii\helpers\Inflector; use yii\mutex\FileMutex; use yii\mutex\MysqlMutex; @@ -1483,4 +1486,69 @@ public static function configure(object $object, array $properties): void $object->$name = $value; } } + + /** + * Returns the path for a CP resource by its URI. + * + * @param string $uri + * @return string|null + * @throws InvalidArgumentException + * @since 4.17.9 + */ + public static function resourcePathByUri(string $uri): ?string + { + if (!Path::ensurePathIsContained($uri)) { + throw new InvalidArgumentException("Invalid resource: $uri"); + } + + $assetManager = Craft::$app->getAssetManager(); + + // If the file already exists, return that + $path = "$assetManager->basePath/$uri"; + if (file_exists($path)) { + return $path; + } + + // Otherwise, publish it + $slash = strpos($uri, '/'); + $hash = substr($uri, 0, $slash); + $sourcePath = self::resourceSourcePathByHash($hash, $assetManager); + + if (!$sourcePath) { + return null; + } + + $filePath = substr($uri, strlen($hash) + 1); + + // Publish the directory + [$publishedDir] = $assetManager->publish(Craft::getAlias($sourcePath)); + + $publishedPath = $publishedDir . DIRECTORY_SEPARATOR . $filePath; + if (!file_exists($publishedPath)) { + throw new InvalidArgumentException("$filePath does not exist."); + } + + return $publishedPath; + } + + /** + * Returns the source path for a CP resource by its hash. + * + * @param string $hash + * @return string|false + * @since 4.17.9 + */ + private static function resourceSourcePathByHash(string $hash, AssetManager $assetManager): string|false + { + try { + return (new Query()) + ->select(['path']) + ->from(Table::RESOURCEPATHS) + ->where(['hash' => $hash]) + ->scalar(); + } catch (DbException) { + // Craft isn't installed yet. See if it's cached as a fallback. + return Craft::$app->getCache()->get($assetManager->getCacheKeyForPathHash($hash)); + } + } }
src/web/Application.php+4 −34 modified@@ -9,7 +9,6 @@ use Craft; use craft\base\ApplicationTrait; -use craft\db\Query; use craft\db\Table; use craft\debug\DeprecatedPanel; use craft\debug\DumpPanel; @@ -38,7 +37,6 @@ use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; use yii\base\InvalidRouteException; -use yii\db\Exception as DbException; use yii\debug\Module as YiiDebugModule; use yii\debug\panels\AssetPanel; use yii\debug\panels\DbPanel; @@ -523,25 +521,11 @@ private function _processResourceRequest(): void } $resourceUri = substr($requestPath, strlen($resourceBaseUri)); - $slash = strpos($resourceUri, '/'); - $hash = substr($resourceUri, 0, $slash); - $sourcePath = $this->resourceSourcePathByHash($hash); - if (!$sourcePath) { - return; - } - - $filePath = substr($resourceUri, strlen($hash) + 1); - if (!Path::ensurePathIsContained($filePath)) { - throw new BadRequestHttpException('Invalid resource path: ' . $filePath); - } - - // Publish the directory - [$publishedDir] = $this->getAssetManager()->publish(Craft::getAlias($sourcePath)); - - $publishedPath = $publishedDir . DIRECTORY_SEPARATOR . $filePath; - if (!file_exists($publishedPath)) { - throw new NotFoundHttpException("$filePath does not exist."); + try { + $publishedPath = App::resourcePathByUri($resourceUri); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), previous: $e); } $response = $this->getResponse(); @@ -558,20 +542,6 @@ private function _processResourceRequest(): void $this->end(); } - private function resourceSourcePathByHash(string $hash): string|false - { - try { - return (new Query()) - ->select(['path']) - ->from(Table::RESOURCEPATHS) - ->where(['hash' => $hash]) - ->scalar(); - } catch (DbException) { - // Craft isn't installed yet. See if it's cached as a fallback. - return Craft::$app->getCache()->get(Craft::$app->getAssetManager()->getCacheKeyForPathHash($hash)); - } - } - /** * Processes install requests. *
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.