VYPR
Moderate severityNVD Advisory· Published Mar 4, 2026· Updated Mar 4, 2026

Craft has a Permission Bypass and IDOR in Duplicate Entry Action

CVE-2026-28782

Description

Craft is a content management system (CMS). Prior to 5.9.0-beta.1 and 4.17.0-beta.1, the "Duplicate" entry action does not properly verify if the user has permission to perform this action on the specific target elements. Even with only "View Entries" permission (where the "Duplicate" action is restricted in the UI), a user can bypass this restriction by sending a direct request. Furthermore, this vulnerability allows duplicating other users' entries by specifying their Entry IDs. Since Entry IDs are incremental, an attacker can trivially brute-force these IDs to duplicate and access restricted content across the system. This vulnerability is fixed in 5.9.0-beta.1 and 4.17.0-beta.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Craft CMS before 5.9.0-beta.1 and 4.17.0-beta.1 contains a permission bypass in the Duplicate entry action, allowing attackers with only View access to duplicate any entry via direct API request.

Craft CMS versions prior to 5.9.0-beta.1 and 4.17.0-beta.1 contain a vulnerability in the "Duplicate" entry action. The action does not properly verify whether the user has permission to perform that action on the target elements. Specifically, the server-side permission check is missing, so even users who have only the "View Entries" permission (which hides the Duplicate button in the UI) can still trigger duplication by sending a direct HTTP request [1][3].

An attacker can exploit this by sending a crafted POST request to the element-indexes/perform-action endpoint, providing the Entry ID of any entry in the system. Because Entry IDs are sequential integers, an attacker can easily brute-force or guess IDs. The only prerequisite is a valid session with "View Entries" permission on any section [3]. The supplied proof-of-concept demonstrates that a user with minimal privileges can duplicate another user's entry, including one the attacker cannot normally view or edit [3].

After successful duplication, the new entry is created with the attacker as the owner, granting them full control over the copied content. This constitutes an insecure direct object reference (IDOR) combined with a missing server‑side authorization check. This can lead to unauthorized access to restricted or private content across the CMS [1][3].

The vulnerability is fixed in Craft CMS version 5.9.0-beta.1 and 4.17.0-beta.1. The fix adds proper permission checks to the Duplicate action, as visible in the referenced commit [2]. Users should update to these or later versions as soon as possible.

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
craftcms/cmsPackagist
>= 5.0.0-RC1, < 5.9.0-beta.15.9.0-beta.1
craftcms/cmsPackagist
>= 4.0.0-RC1, < 4.17.0-beta.14.17.0-beta.1

Affected products

2

Patches

1
fb61a91357f5

Fixed GHSA-jxm3-pmm2-9gf6

https://github.com/craftcms/cmsbrandonkellyJan 13, 2026via ghsa
7 files changed · +30 37
  • CHANGELOG-WIP.md+1 0 modified
    @@ -37,3 +37,4 @@
     - Fixed a bug where Table fields with the “Static Rows” setting enabled would lose track of which values belonged to which row headings, if the “Default Values” table was reordered. ([#17090](https://github.com/craftcms/cms/issues/17090))
     - Fixed a bug where deadlocks could occur when updating elements’ search indexes. ([#18139](https://github.com/craftcms/cms/pull/18139))
     - Fixed an RCE vulnerability. (GHSA-v47q-jxvr-p68x)
    +- Fixed a permission escalation vulnerability. (GHSA-jxm3-pmm2-9gf6)
    
  • src/elements/actions/DeleteUsers.php+5 21 modified
    @@ -78,14 +78,14 @@ public function getTriggerHtml(): ?string
             }
     
             Craft::$app->getView()->registerJsWithVars(
    -            fn($type, $undeletableIds, $redirect) => <<<JS
    +            fn($type, $redirect) => <<<JS
     (() => {
         new Craft.ElementActionTrigger({
             type: $type,
             bulk: true,
             validateSelection: \$selectedItems => {
                 for (let i = 0; i < \$selectedItems.length; i++) {
    -                if ($.inArray(\$selectedItems.eq(i).find('.element').data('id').toString(), $undeletableIds) != -1) {
    +                if (!Garnish.hasAttr(\$selectedItems.eq(i).find('.element'), 'data-deletable')) {
                         return false;
                     }
                 }
    @@ -116,7 +116,6 @@ public function getTriggerHtml(): ?string
     JS,
                 [
                     static::class,
    -                $this->_getUndeletableUserIds(),
                     Craft::$app->getSecurity()->hashData(Craft::$app->getEdition() === Craft::Pro ? 'users' : 'dashboard'),
                 ]);
     
    @@ -144,7 +143,6 @@ public function performAction(ElementQueryInterface $query): bool
         {
             /** @var User[] $users */
             $users = $query->all();
    -        $undeletableIds = $this->_getUndeletableUserIds();
     
             // Are we transferring the user’s content to a different user?
             if (is_array($this->transferContentTo)) {
    @@ -163,9 +161,11 @@ public function performAction(ElementQueryInterface $query): bool
     
             // Delete the users
             $elementsService = Craft::$app->getElements();
    +        $currentUser = Craft::$app->getUser()->getIdentity();
             $deletedCount = 0;
    +
             foreach ($users as $user) {
    -            if (!in_array($user->id, $undeletableIds, false)) {
    +            if ($elementsService->canDelete($user, $currentUser)) {
                     $user->inheritorOnDelete = $transferContentTo;
                     if ($elementsService->deleteElement($user, $this->hard)) {
                         $deletedCount++;
    @@ -193,20 +193,4 @@ public function performAction(ElementQueryInterface $query): bool
     
             return true;
         }
    -
    -    /**
    -     * Returns a list of the user IDs that can't be deleted.
    -     *
    -     * @return array
    -     */
    -    private function _getUndeletableUserIds(): array
    -    {
    -        if (!Craft::$app->getUser()->getIsAdmin()) {
    -            // Only admins can delete other admins
    -            return User::find()->admin()->ids();
    -        }
    -
    -        // Can't delete your own account from here
    -        return [Craft::$app->getUser()->getIdentity()->id];
    -    }
     }
    
  • src/elements/actions/Duplicate.php+5 0 modified
    @@ -109,8 +109,13 @@ private function _duplicateElements(ElementQueryInterface $query, array $element
         {
             $elementsService = Craft::$app->getElements();
             $structuresService = Craft::$app->getStructures();
    +        $user = Craft::$app->getUser()->getIdentity();
     
             foreach ($elements as $element) {
    +            if (!$elementsService->canDuplicate($element, $user)) {
    +                continue;
    +            }
    +
                 // Make sure this element wasn't already duplicated, which could
                 // happen if it's the descendant of a previously duplicated element
                 // and $this->deep == true.
    
  • src/elements/actions/Restore.php+13 6 modified
    @@ -79,24 +79,25 @@ public function getTriggerLabel(): string
          */
         public function getTriggerHtml(): ?string
         {
    -        if ($this->restorableElementsOnly) {
    -            // Only enable for deletable elements, per canDelete()
    -            Craft::$app->getView()->registerJsWithVars(fn($type) => <<<JS
    +        // Only enable for restorable/savable elements
    +        Craft::$app->getView()->registerJsWithVars(fn($type, $attribute) => <<<JS
     (() => {
         new Craft.ElementActionTrigger({
             type: $type,
             validateSelection: \$selectedItems => {
                 for (let i = 0; i < \$selectedItems.length; i++) {
    -                if (!Garnish.hasAttr(\$selectedItems.eq(i).find('.element'), 'data-restorable')) {
    +                if (!Garnish.hasAttr(\$selectedItems.eq(i).find('.element'), $attribute)) {
                         return false;
                     }
                 }
                 return true;
             },
         });
     })();
    -JS, [static::class]);
    -        }
    +JS, [
    +            static::class,
    +            $this->restorableElementsOnly ? 'data-restorable' : 'data-savable',
    +        ]);
     
             return '<div class="btn formsubmit">' . $this->getTriggerLabel() . '</div>';
         }
    @@ -109,7 +110,13 @@ public function performAction(ElementQueryInterface $query): bool
             $anySuccess = false;
             $anyFail = false;
             $elementsService = Craft::$app->getElements();
    +        $user = Craft::$app->getUser()->getIdentity();
    +
             foreach ($query->all() as $element) {
    +            if (!$elementsService->canSave($element, $user)) {
    +                continue;
    +            }
    +
                 if ($elementsService->restoreElement($element)) {
                     $anySuccess = true;
                 } else {
    
  • src/elements/actions/SetStatus.php+3 8 modified
    @@ -85,21 +85,16 @@ public function performAction(ElementQueryInterface $query): bool
             $elementType = $this->elementType;
             $isLocalized = $elementType::isLocalized() && Craft::$app->getIsMultiSite();
             $elementsService = Craft::$app->getElements();
    +        $user = Craft::$app->getUser()->getIdentity();
     
             $elements = $query->all();
             $failCount = 0;
     
    -        // Make sure the user has permission to edit each of the elements
             foreach ($elements as $element) {
    -            if (!$elementsService->canSave($element)) {
    -                $this->setMessage(Craft::t('app', 'Couldn’t save {type}.', [
    -                    'type' => count($elements) === 1 ? $elementType::lowerDisplayName() : $elementType::pluralLowerDisplayName(),
    -                ]));
    -                return false;
    +            if (!$elementsService->canSave($element, $user)) {
    +                continue;
                 }
    -        }
     
    -        foreach ($elements as $element) {
                 switch ($this->status) {
                     case self::ENABLED:
                         // Skip if there's nothing to change
    
  • src/elements/Asset.php+1 1 modified
    @@ -3155,7 +3155,7 @@ protected function htmlAttributes(string $context): array
                 $attributes['data']['editable-image'] = true;
             }
     
    -        if ($this->dateDeleted && $this->keptFile) {
    +        if ($this->dateDeleted && $this->keptFile && Craft::$app->getElements()->canSave($this)) {
                 $attributes['data']['restorable'] = true;
             }
     
    
  • src/elements/User.php+2 1 modified
    @@ -1435,7 +1435,8 @@ public function canDelete(User $user): bool
     
             return (
                 $user->id !== $this->id &&
    -            $user->can('deleteUsers')
    +            $user->can('deleteUsers') &&
    +            (!$this->admin || $user->admin)
             );
         }
     
    

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.