VYPR
High severityNVD Advisory· Published Mar 16, 2026· Updated Mar 18, 2026

Craft CMS Vulnerable to Privilege Escalation/Bypass through UsersController->actionImpersonateWithToken()

CVE-2026-32267

Description

Craft CMS is a content management system (CMS). From version 4.0.0-RC1 to before version 4.17.6 and from version 5.0.0-RC1 to before version 5.9.12, a low-privilege user (or an unauthenticated user who has been sent a shared URL) can escalate their privileges to admin by abusing UsersController->actionImpersonateWithToken. This issue has been patched in versions 4.17.6 and 5.9.12.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
craftcms/cmsPackagist
>= 4.0.0-RC1, < 4.17.64.17.6
craftcms/cmsPackagist
>= 5.0.0-RC1, < 5.9.125.9.12

Affected products

1

Patches

1
6301e217c5f1

Fixed GHSA-cc7p-2j3x-x7xf

https://github.com/craftcms/cmsbrandonkellyFeb 18, 2026via ghsa
6 files changed · +94 18
  • CHANGELOG.md+6 0 modified
    @@ -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()`.
    
  • src/helpers/UrlHelper.php+7 1 modified
    @@ -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']) &&
    
  • src/services/Tokens.php+43 6 modified
    @@ -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.
          *
    
  • src/web/Controller.php+2 1 modified
    @@ -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');
             }
         }
    
  • src/web/Request.php+34 5 modified
    @@ -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);
    
  • src/web/UrlManager.php+2 5 modified
    @@ -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 function _getTokenRoute(Request $request): array|false
                 ], __METHOD__);
             }
     
    -        if ($token === null) {
    -            return false;
    -        }
    -
    -        return Craft::$app->getTokens()->getTokenRoute($token);
    +        return $route ?? false;
         }
     }
    

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

4

News mentions

0

No linked articles in our index yet.