GHSA-vfmf-q6x9-cw96

Suggest an improvement
Source
https://github.com/advisories/GHSA-vfmf-q6x9-cw96
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-vfmf-q6x9-cw96/GHSA-vfmf-q6x9-cw96.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-vfmf-q6x9-cw96
Aliases
Published
2026-09-17T20:43:43Z
Modified
2026-09-17T21:00:04Z
Severity
  • 8.7 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N CVSS Calculator
  • 9.3 (Critical) CVSS_V4 - CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N CVSS Calculator
Summary
Grav: detectXss() misses an event-handler attribute after an unpaired quote in an unquoted attribute value, giving stored XSS
Details

Affected versions and vulnerable location

  • Confirmed on grav core at 78ebfc1 (tag 2.0.13).
  • Detector: system/src/Grav/Common/Security.php:290, the on_events regex, run via patternMatches() (:315-330).
  • The on_events pattern 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:160 scalars, :165 arrays), invoked per field from BlueprintSchema::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 (for onerror the 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.

Database specific
{
    "cwe_ids": [
        "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-17T20:43:43Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
}
References

Affected packages

Packagist / getgrav/grav

Package

Name
getgrav/grav
Purl
pkg:composer/getgrav/grav

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
2.0.15

Affected versions

0.*
0.8.0
0.9.0
0.9.1
0.9.2
0.9.3
0.9.4
0.9.5
0.9.6
0.9.7
0.9.8
0.9.9
0.9.10
0.9.11
0.9.12
0.9.13
0.9.14
0.9.15
0.9.16
0.9.17
0.9.18
0.9.19
0.9.20
0.9.21
0.9.22
0.9.23
0.9.24
0.9.25
0.9.26
0.9.27
0.9.28
0.9.29
0.9.30
0.9.31
0.9.32
0.9.33
0.9.34
0.9.35
0.9.36
0.9.37
0.9.38
0.9.39
0.9.40
0.9.41
0.9.42
0.9.43
0.9.44
0.9.45
1.*
1.0.0-rc.1
1.0.0-rc.2
1.0.0-rc.3
1.0.0-rc.4
1.0.0-rc.5
1.0.0-rc.6
1.0.0
1.0.1
1.0.2
1.0.3
1.0.4
1.0.5
1.0.6
1.0.7
1.0.8
1.0.9
1.0.10
1.1.0-beta.1
1.1.0-beta.2
1.1.0-beta.3
1.1.0-beta.4
1.1.0-beta.5
1.1.0-rc.1
1.1.0-rc.2
1.1.0-rc.3
1.1.0
1.1.1
1.1.2
1.1.3
1.1.4
1.1.5
1.1.6
1.1.7
1.1.8
1.1.9-rc.1
1.1.9-rc.2
1.1.9-rc.3
1.1.9
1.1.10
1.1.11
1.1.12
1.1.13
1.1.14
1.1.15
1.1.16
1.1.17
1.2.0-rc.1
1.2.0-rc.2
1.2.0-rc.3
1.2.0
1.2.1
1.2.2
1.2.3
1.2.4
1.3.0-rc.1
1.3.0-rc.2
1.3.0-rc.3
1.3.0-rc.4
1.3.0-rc.5
1.3.0
1.3.1
1.3.2
1.3.3
1.3.4
1.3.5
1.3.6
1.3.7
1.3.8
1.3.9
1.3.10
1.4.0-beta.1
1.4.0-beta.2
1.4.0-beta.3
1.4.0-rc.1
1.4.0-rc.2
1.4.0
1.4.1
1.4.2
1.4.3
1.4.4
1.4.5
1.4.6
1.4.7
1.4.8
1.5.0-beta.1
1.5.0-beta.2
1.5.0-rc.1
1.5.0
1.5.1
1.5.2
1.5.3
1.5.4
1.5.5
1.5.6
1.5.7
1.5.8
1.5.9
1.5.10
1.6.0-beta.1
1.6.0-beta.2
1.6.0-beta.3
1.6.0-beta.4
1.6.0-beta.5
1.6.0-beta.6
1.6.0-beta.7
1.6.0-beta.8
1.6.0-rc.1
1.6.0-rc.2
1.6.0-rc.3
1.6.0-rc.4
1.6.0
1.6.1
1.6.2
1.6.3
1.6.4
1.6.5
1.6.6
1.6.7
1.6.8
1.6.9
1.6.10
1.6.11
1.6.12
1.6.13
1.6.14
1.6.15
1.6.16
1.6.17
1.6.18
1.6.19
1.6.20
1.6.21
1.6.22
1.6.23
1.6.24
1.6.25
1.6.26
1.6.27
1.6.28
1.6.29
1.6.30
1.6.31
1.7.0-beta.1
1.7.0-beta.2
1.7.0-beta.3
1.7.0-beta.4
1.7.0-beta.5
1.7.0-beta.6
1.7.0-beta.7
1.7.0-beta.8
1.7.0-beta.9
1.7.0-beta.10
1.7.0-rc.1
1.7.0-rc.2
1.7.0-rc.3
1.7.0-rc.4
1.7.0-rc.5
1.7.0-rc.6
1.7.0-rc.7
1.7.0-rc.8
1.7.0-rc.9
1.7.0-rc.10
1.7.0-rc.11
1.7.0-rc.12
1.7.0-rc.13
1.7.0-rc.14
1.7.0-rc.15
1.7.0-rc.16
1.7.0-rc.17
1.7.0-rc.18
1.7.0-rc.19
1.7.0-rc.20
1.7.0
1.7.1
1.7.3
1.7.4
1.7.5
1.7.6
1.7.7
1.7.8
1.7.9
1.7.10
1.7.12
1.7.13
1.7.14
1.7.15
1.7.16
1.7.17
1.7.18
1.7.19
1.7.20
1.7.21
1.7.22
1.7.23
1.7.24
1.7.25
1.7.26
1.7.26.1
1.7.27
1.7.27.1
1.7.28
1.7.29
1.7.29.1
1.7.30
1.7.31
1.7.32
1.7.33
1.7.34
1.7.35
1.7.36
1.7.37
1.7.37.1
1.7.38
1.7.39
1.7.39.1
1.7.39.2
1.7.39.3
1.7.39.4
1.7.40
1.7.41
1.7.41.1
1.7.41.2
1.7.42
1.7.42.1
1.7.42.2
1.7.42.3
1.7.43
1.7.44
1.7.45
1.7.46
1.7.47
1.7.48
1.7.49
1.7.49.1
1.7.49.2
1.7.49.3
1.7.49.4
1.7.49.5
1.7.51
1.7.52
1.7.53
1.7.53.1
1.7.53.2
1.7.53.3
1.8.0-beta.1
1.8.0-beta.2
1.8.0-beta.3
1.8.0-beta.4
1.8.0-beta.5
1.8.0-beta.6
1.8.0-beta.7
1.8.0-beta.8
1.8.0-beta.9
1.8.0-beta.10
1.8.0-beta.11
1.8.0-beta.12
1.8.0-beta.13
1.8.0-beta.14
1.8.0-beta.15
1.8.0-beta.16
1.8.0-beta.17
1.8.0-beta.18
1.8.0-beta.19
1.8.0-beta.20
1.8.0-beta.21
1.8.0-beta.22
1.8.0-beta.23
1.8.0-beta.24
1.8.0-beta.25
1.8.0-beta.26
1.8.0-beta.27
1.8.0-beta.28
1.8.0-beta.29
2.*
2.0.0-beta.1
2.0.0-beta.2
2.0.0-beta.3
2.0.0-beta.4
2.0.0-rc.1
2.0.0-rc.2
2.0.0-rc.3
2.0.0-rc.4
2.0.0-rc.5
2.0.0-rc.6
2.0.0-rc.7
2.0.0-rc.8
2.0.0-rc.9
2.0.0-rc.10
2.0.0
2.0.1
2.0.2
2.0.3
2.0.4
2.0.5
2.0.6
2.0.7
2.0.8
2.0.9
2.0.10
2.0.11
2.0.12
2.0.13
2.0.14

Database specific

last_known_affected_version_range
"<= 2.0.14"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-vfmf-q6x9-cw96/GHSA-vfmf-q6x9-cw96.json"