GitHub Security Advisory: GHSA-w4rc-p66m-x6qq
Package: getgrav/grav-plugin-form (COMPOSER)
Severity: HIGH (CVSS 0.0)
CWE: CWE-20: Improper Input Validation
Vulnerable versions: < 9.1.0
Patched in: 9.1.0

Summary: Grav Form Plugin has an Anonymous Page Content Overwrite via Form File Upload filename Override

Details:
### Summary
(Tested on Form 9.0.3 released on April, 28th)

The 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.

A 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:

1. Upload **arbitrary content** with **`filename=form.md`** (or other page-content filenames),
2. Submit the form to trigger `Form::copyFiles()`, which **overwrites the page's own `.md` file**.

### Details
**Vulnerable code path**

`user/plugins/form/classes/Form.php:580-606` (in `uploadFiles()`):
```php
$grav->fireEvent('onFormUploadSettings', new Event(['settings' => &$settings, 'post' => $post]));

$upload = json_decode(json_encode($this->normalizeFiles($_FILES['data'], $settings->name)), true);
$filename = $post['filename'] ?? $upload['file']['name'];           // ← POST-controlled
// ...
if (!Utils::checkFilename($filename)) {                              // ← extension blocklist only
    return ['status' => 'error', 'message' => 'Bad filename'];
}
```

`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**.

The MIME check (lines 627-654) uses `Utils::getMimeByFilename($filename)` against the blueprint's `accept` list. With `accept: ['*']`, all filenames pass.

After 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->moveTo($destination)`:
```php
$destination = $upload->getDestination();   // ← determined at upload time:
                                            //   $destination = $page_dir . '/' . $filename
$folder = $filesystem->dirname($destination);
if (!is_dir($folder) && !@mkdir($folder, 0777, true) && !is_dir($folder)) { ... }
$upload->moveTo($destination);
```

`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**.

A Grav page's `.md` file is parsed as YAML frontmatter + Markdown content. Whatever content the attacker uploaded becomes the new page definition.

### PoC

**Setup** :

Any existing page with a form like this — a "generic upload" form is the realistic case:
```yaml
---
title: Upload your file
form:
    name: upform
    fields:
        - {name: img, type: file, multiple: false, accept: ['*']