Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-vfmf-q6x9-cw96
Affected versions and vulnerable location
- Confirmed on grav core at
78ebfc1(tag 2.0.13). - Detector:
system/src/Grav/Common/Security.php:290, theon_eventsregex, run viapatternMatches()(:315-330). - The
on_eventspattern at HEAD:#<(?:"[^"]*"|'[^']*'|[^>"'])*?(?:[\s\x00-\x20"'/]|"[^"]*"|'[^']*')on\s*[a-z]+\s*=#iu - Sole save-time guard for non-super content:
Validation::checkSafety()(system/src/Grav/Common/Data/Validation.php:160scalars,:165arrays), invoked per field fromBlueprintSchema::validate->Validation::checkSafety(system/src/Grav/Common/Data/BlueprintSchema.php:248).security.xss_whitelist: [admin.super]exempts only super-admins (Validation.php:148).
Root cause (distinct from GHSA-269c)
GHSA-269c hardened the tag-body scan to be quote-aware so a > inside a paired quoted attribute value is treated as data, not a tag close. That same quote-awareness opened a new gap: the regex treats ANY " or ' as a string delimiter, but HTML only enters a quoted-value state when a quote appears immediately after =. A single unpaired quote sitting inside an unquoted attribute value is, to the browser, just a value character; to the regex it is an unterminated string that neither [^>"'] nor "[^"]*" can consume, so the lazy tag-body scan cannot advance past it to reach the following on...= handler. No alignment matches and detectXss() returns null.
Proof (executed)
The detector was replicated verbatim (the on_events regex plus patternMatches) with the shipped system/config/security.yaml defaults and run under PHP. Observed:
baseline <img src=x onerror=alert(1)> => blocked (on_events)
GHSA-269c <img src=x title=">" onerror=alert(1)> => blocked (on_events) # prior fix works
BYPASS A <img src=x" onerror=alert(1)> => PASSES (no XSS detected)
BYPASS B <img title=x" onerror=alert(1)> => PASSES (no XSS detected)
BYPASS C <a href=x" onmouseover=alert(1)>x</a> => PASSES (no XSS detected)
BYPASS D <img src=x' onerror=alert(1)> => PASSES (no XSS detected)
BYPASS E <div id=x" onmouseover=alert(1)>hover</div> => PASSES (no XSS detected)
Browser tokenization of <img src=x" onerror=alert(1)>: src takes the unquoted value x" (space ends it), onerror is parsed as a separate live attribute, src 404s and onerror fires. None of the other rules cover it: img/a/div are not in xss_dangerous_tags, there is no javascript:/data: scheme and no style/url/expression.
Reachability
checkSafety() is the only save-time XSS screen for a non-super editor. The pages blueprint validates header.title (type: text) and page content (markdown/textarea) with xss_check on, so a bare onerror= is rejected but the payload above is stored verbatim. Page content is emitted through {{ page.content|raw }} and raw inline HTML passes Parsedown by default (markdown.escape_markup: false), so the handler runs for every visitor. The same detector core also backs Security::detectXssInEditorContent() (the GHSA-2c4f render-time-Twig save gate; callers Page.php:1359, Flex/Types/Pages/PageObject.php:194), the detectXssFromPages() admin scanner (Admin.php:2096), and the xss() Twig function, so all of them report the payload clean.
Auth required: an authenticated content editor with page/form edit rights but WITHOUT admin.super.
Suggested fix
Make the tag-body scan treat a quote as a delimiter only in the after-= position, or normalize unquoted attribute values before the handler scan, so an unpaired quote inside an unquoted value cannot mask a following on...=. A targeted addition: also flag on<name>= sequences that appear after a lone unbalanced quote within the same tag. Because detectXss() is a denylist, consider additionally encoding "/' in stored non-super content, or defaulting markdown.escape_markup: true for non-super authors.
Severity and CVSS reasoning
Suggested severity: High (matches GHSA-269c and the other stored-XSS advisories in this codebase).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N (8.7).
PR:L: a non-super editor account.UI:R: a visitor renders the page (foronerrorthe image simply loads/fails automatically).S:C/C:H/I:H: script in the site origin against every visitor, including admins viewing the content.
How I found it and a note on tooling
I read detectXss() and reasoned about the difference between the HTML tokenizer's quoted-value state and the regex's string handling, then replicated the exact on_events pattern and patternMatches() with the shipped default config and ran the payloads to confirm the bypass and that the GHSA-269c payload is still blocked. I used AI assistance for the analysis and drafting and verified the detector behavior by execution. This is executed against a faithful replica of the detector with production config, not against a full running Grav site.
Upload your own SBOM in CycloneDX 1.6 or higher (JSON) directly here to check your vulnerabilities.
Drag and drop some file here, or click to select
The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker needs basic access or low-level privileges. The attacker needs the user to perform some action, like clicking a link. The vulnerability can affect other systems as well, not just the initial system. There is a high impact on the confidentiality of the information. There is a high impact on the integrity of the data.
Exploitation activity has been observed. Apply available patches or mitigations urgently.
The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard