ANT-2026-ZQ8AY22X · craftcms/cms

privilege-escalation high

GHSA-cc7p-2j3x-x7xf

Severity Claude high · Security research firm - · Maintainer high

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis of this finding, sealed at approval.

ANT-2026-ZQ8AY22X: Privilege Escalation/Bypass through UsersController->actionImpersonateWithToken()

Craft CMS's actionPreview() re-dispatches requests with $skipSpecialHandling=true and $checkToken=false, allowing an attacker-supplied action query parameter to redirect execution to UsersController::actionImpersonateWithToken(). That endpoint's only guard, requireToken(), merely checks the boolean _hadToken set when the preview token was resolved, without verifying the token was minted for impersonation. Because the action is also listed in $allowAnonymous, no prior authentication is enforced. An editor (or anyone holding a shared preview URL) can therefore append &action=users/impersonate-with-token&userId=1&prevUserId=1 to a preview URL and be logged in as user 1 (admin).

Target

Project: craftcms/cms
Discovery: static analysis — not yet dynamically reproduced

Technical Details

Root cause is a confused-deputy between the preview dispatcher and the impersonation endpoint: actionPreview() passes $skipSpecialHandling=true to handleRequest() and $checkToken=false to checkIfActionRequest(), so security guards are skipped and the action parameter is attacker-controlled. requireToken() on actionImpersonateWithToken() only inspects _hadToken (set for any valid token) rather than validating that the token was issued for this route, and the action is in $allowAnonymous, so no further authorization occurs.

Reproduction

This finding was identified by static analysis and has not yet been dynamically reproduced. The Technical Details section above describes the code path; a trigger input is not included.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-ZQ8AY22X.


Reference: ANT-2026-ZQ8AY22X
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

UPSTREAM FIX

The change that resolved this finding.

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a940756613..ddca7497e41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Release Notes for Craft CMS 4
 
+## Unreleased
+
+- Added `craft\services\Tokens::getRemainingTokenUsages()`.
+- Added `craft\web\Request::getTokenRoute()`.
+- Fixed a [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) permission escalation vulnerability. (GHSA-cc7p-2j3x-x7xf)
+
 ## 4.17.5 - 2026-02-17
 
 - Added `craft\web\Request::getWantsImage()`.
diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php
index c29f01f2a6a..737b3b90443 100644
--- a/src/helpers/UrlHelper.php
+++ b/src/helpers/UrlHelper.php
@@ -658,9 +658,15 @@ private static function _createUrl(
                 $params[$generalConfig->siteToken] = $siteToken;
             }
             if ($request->getIsSiteRequest()) {
-                if ($addToken && !isset($params[$generalConfig->tokenParam]) && ($token = $request->getToken()) !== null) {
+                if (
+                    $addToken &&
+                    !isset($params[$generalConfig->tokenParam]) &&
+                    ($token = $request->getToken()) !== null &&
+                    Craft::$app->getTokens()->getRemainingTokenUsages($token) !== 0
+                ) {
                     $params[$generalConfig->tokenParam] = $token;
                 }
+
                 if (
                     !isset($params['x-craft-preview']) &&
                     !isset($params['x-craft-live-preview']) &&
diff --git a/src/services/Tokens.php b/src/services/Tokens.php
index 37dff3c34b0..a3cb272a6d7 100644
--- a/src/services/Tokens.php
+++ b/src/services/Tokens.php
@@ -34,6 +34,12 @@ class Tokens extends Component
      */
     private bool $_deletedExpiredTokens = false;
 
+    /**
+     * @var array<string,int|null>
+     * @see getRemainingTokenUsages()
+     */
+    private array $_remainingTokenUsages = [];
+
     /**
      * Creates a new token and returns it.
      * ---
@@ -137,24 +143,25 @@ public function getTokenRoute(string $token): array|false
                 ->one();
 
             if (!$result) {
-                // Remove it from the request  so it doesn’t get added to generated URLs
-                Craft::$app->getRequest()->setToken(null);
+                $this->_remainingTokenUsages[$token] = 0;
                 return false;
             }
 
             // Usage limit enforcement (for future requests)
             if ($result['usageLimit']) {
                 // Does it have any more life after this?
-                if ($result['usageCount'] < $result['usageLimit'] - 1) {
+                $newUsageCount = $result['usageCount'] + 1;
+                if ($newUsageCount < $result['usageLimit']) {
                     // Increment its count
                     $this->incrementTokenUsageCountById($result['id']);
+                    $this->_remainingTokenUsages[$token] = $result['usageLimit'] - $newUsageCount;
                 } else {
                     // Just delete it
                     $this->deleteTokenById($result['id']);
-
-                    // Remove it from the request as well so it doesn’t get added to generated URLs
-                    Craft::$app->getRequest()->setToken(null);
+                    $this->_remainingTokenUsages[$token] = 0;
                 }
+            } else {
+                $this->_remainingTokenUsages[$token] = null;
             }
 
             return (array)Json::decodeIfJson($result['route']);
@@ -163,6 +170,36 @@ public function getTokenRoute(string $token): array|false
         }
     }
 
+    /**
+     * Returns the remaining usage count for a given token, if it has a limit.
+     *
+     * @param string $token
+     * @return int|null
+     * @since 4.17.6
+     */
+    public function getRemainingTokenUsages(string $token): ?int
+    {
+        if (!array_key_exists($token, $this->_remainingTokenUsages)) {
+            $result = (new Query())
+                ->select(['usageLimit', 'usageCount'])
+                ->from([Table::TOKENS])
+                ->where(['token' => $token])
+                ->one();
+
+            if ($result) {
+                if ($result['usageLimit']) {
+                    $this->_remainingTokenUsages[$token] = $result['usageLimit'] - $result['usageCount'];
+                } else {
+                    $this->_remainingTokenUsages[$token] = null;
+                }
+            } else {
+                $this->_remainingTokenUsages[$token] = 0;
+            }
+        }
+
+        return $this->_remainingTokenUsages[$token];
+    }
+
     /**
      * Increments a token's usage count.
      *
diff --git a/src/web/Controller.php b/src/web/Controller.php
index a5c16a9281f..9c7b5f9da63 100644
--- a/src/web/Controller.php
+++ b/src/web/Controller.php
@@ -526,7 +526,8 @@ public function requireAcceptsJson(): void
      */
     public function requireToken(): void
     {
-        if (!$this->request->getHadToken()) {
+        $tokenRoute = $this->request->getTokenRoute()[0] ?? null;
+        if ($tokenRoute !== $this->getRoute()) {
             throw new BadRequestHttpException('Valid token required');
         }
     }
diff --git a/src/web/Request.php b/src/web/Request.php
index f854e2081a2..a5b36d5730a 100644
--- a/src/web/Request.php
+++ b/src/web/Request.php
@@ -17,6 +17,7 @@
 use craft\helpers\StringHelper;
 use craft\models\Site;
 use craft\services\Sites;
+use craft\services\Tokens;
 use yii\base\InvalidArgumentException;
 use yii\base\InvalidConfigException;
 use yii\db\Exception as DbException;
@@ -197,6 +198,12 @@ class Request extends \yii\web\Request
      */
     public ?string $_token = null;
 
+    /**
+     * @var array|null
+     * @see getTokenRoute()
+     */
+    public ?array $_tokenRoute = null;
+
     /**
      * @inheritdoc
      */
@@ -510,7 +517,7 @@ public function getHadToken(): bool
      *
      * @return string|null The token, or `null` if there isn’t one.
      * @throws BadRequestHttpException if an invalid token is supplied
-     * @see \craft\services\Tokens::createToken()
+     * @see Tokens::createToken()
      * @see Controller::requireToken()
      */
     public function getToken(): ?string
@@ -519,6 +526,21 @@ public function getToken(): ?string
         return $this->_token;
     }
 
+    /**
+     * Returns the route the request’s token resolves to.
+     *
+     * @return array|null The route, or `null` if there isn’t one.
+     * @throws BadRequestHttpException if an invalid token is supplied
+     * @see getToken())
+     * @see Tokens::createToken()
+     * @since 4.17.6
+     */
+    public function getTokenRoute(): ?array
+    {
+        $this->_findToken();
+        return $this->_tokenRoute;
+    }
+
     /**
      * Sets the token value.
      *
@@ -549,10 +571,17 @@ private function _findToken(): void
 
         $this->_token = ($this->getQueryParam($this->generalConfig->tokenParam) ?? $this->getHeaders()->get('X-Craft-Token')) ?: null;
 
-        if ($this->_token && !preg_match('/^[A-Za-z0-9_-]+$/', $this->_token)) {
-            $this->_token = null;
-            $this->_hadToken = false;
-            throw new BadRequestHttpException('Invalid token');
+        if ($this->_token) {
+            if (!preg_match('/^[A-Za-z0-9_-]+$/', $this->_token)) {
+                $this->_token = null;
+                $this->_hadToken = false;
+                throw new BadRequestHttpException('Invalid token');
+            }
+
+            $this->_tokenRoute = Craft::$app->getTokens()->getTokenRoute($this->_token) ?: null;
+            if (!$this->_tokenRoute) {
+                $this->_token = null;
+            }
         }
 
         $this->_hadToken = isset($this->_token);
diff --git a/src/web/UrlManager.php b/src/web/UrlManager.php
index 9af83c76b5d..4c3e2f9b0a2 100644
--- a/src/web/UrlManager.php
+++ b/src/web/UrlManager.php
@@ -548,6 +548,7 @@ private function _getTokenRoute(Request $request): array|false
         }
 
         $token = $request->getToken();
+        $route = $request->getTokenRoute();
 
         if (App::devMode()) {
             Craft::debug([
@@ -557,10 +558,6 @@ private
… (truncated)

https://github.com/craftcms/cms/commit/6301e217c5f15617d939c432cb770db50af14b33

ADVISORY

https://github.com/craftcms/cms/security/advisories/GHSA-cc7p-2j3x-x7xf

TIMELINE

Recorded dates, in order.

  1. 2026-02-18 Sent to maintainer
  2. 2026-02-18 Patch released
  3. 2026-03-29 Discovered or logged
  4. 2026-05-08 Maintainer acknowledged
  5. 2026-05-20 Publicly revealed
PROVENANCE

SHA-3-512 hash:

dc75439eef02f2f72ba1440db7ca5fbae9599c252caba20b38f6b5c634fae74db645c91470b08c00012cc38b7353a9a7f79f52cf703bb1a668c6df7682c8907f

Committed 2026-05-08 16:37 UTC

Revealed 2026-05-20 07:40 UTC

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-ZQ8AY22X",
  "bug_class": "privilege-escalation",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-03-29T20:43:35+00:00",
  "description": "Craft CMS's actionPreview() re-dispatches requests with $skipSpecialHandling=true and $checkToken=false, allowing an attacker-supplied action query parameter to redirect execution to UsersController::actionImpersonateWithToken(). That endpoint's only guard, requireToken(), merely checks the boolean _hadToken set when the preview token was resolved, without verifying the token was minted for impersonation. Because the action is also listed in $allowAnonymous, no prior authentication is enforced. An editor (or anyone holding a shared preview URL) can therefore append &action=users/impersonate-with-token&userId=1&prevUserId=1 to a preview URL and be logged in as user 1 (admin).",
  "discovered_at": null,
  "location": null,
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "CraftCMS",
  "reproduction": null,
  "technical_details": "Root cause is a confused-deputy between the preview dispatcher and the impersonation endpoint: actionPreview() passes $skipSpecialHandling=true to handleRequest() and $checkToken=false to checkIfActionRequest(), so security guards are skipped and the action parameter is attacker-controlled. requireToken() on actionImpersonateWithToken() only inspects _hadToken (set for any valid token) rather than validating that the token was issued for this route, and the action is in $allowAnonymous, so no further authorization occurs.",
  "title": "Privilege Escalation/Bypass through UsersController->actionImpersonateWithToken()",
  "vendor_severity": null
}