GHSA-q2j8-x8hf-63ch

Suggest an improvement
Source
https://github.com/advisories/GHSA-q2j8-x8hf-63ch
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-q2j8-x8hf-63ch/GHSA-q2j8-x8hf-63ch.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-q2j8-x8hf-63ch
Aliases
Published
2026-09-17T20:44:39Z
Modified
2026-09-17T21:00:04Z
Severity
  • 5.4 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N CVSS Calculator
  • 5.1 (Medium) CVSS_V4 - CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N CVSS Calculator
Summary
Grav: Single invalid UTF-8 byte disables every rule in Security::detectXss(), bypassing the page-content XSS safety gate
Details

Vulnerability Details

Component: getgrav/grav core File: system/src/Grav/Common/Security.php Function: detectXss() (all six entries in the $patterns array use the PCRE u modifier), invoked from Grav\Common\Data\Validation::checkSafety() (the save-time XSS gate for any non-security.xss_whitelist account's blueprint field, including the page content field) and detectXssInEditorContent() (the render-time backstop for GHSA-2c4f-86xc-cr74) CWE: CWE-79 (Stored XSS), root-caused by CWE-20 (Improper Input Validation — fails open on malformed input) Severity: High CVSS: 8.0 — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

Relationship to prior advisories

This project's detectXss()/checkSafety() stack has been patched at least three times for the "page editor without super-admin rights stores an event handler that runs for site visitors" bug class: GHSA-9695-8fr9-hw5q / GHSA-c2q3-p4jr-c55f / GHSA-w8cg-7jcj-4vv2 (unquoted-attribute bypasses), GHSA-269c-h76q-8cxw (quoted-attribute-boundary bypass), GHSA-2c4f-86xc-cr74 (render-time Twig-assembled bypass). All three patched the regex logic. This is a different, lower-level defect: the PHP regex engine silently refuses to evaluate the pattern at all once the input contains one invalid UTF-8 byte, independent of what the regex logic says — no amount of regex-logic hardening fixes this.

Root Cause

Every pattern in $patterns uses the PCRE u (UTF-8) modifier. PHP's documented behavior: if the subject string contains even one byte sequence that is not valid UTF-8, preg_match() does not "skip" that byte or report "no match" — it returns false for the entire call, with preg_last_error() === PREG_BAD_UTF8_ERROR. detectXss() only checks truthiness (if (preg_match(...) || preg_match(...))), so false and "0 matches" are indistinguishable to the calling code. A single stray byte anywhere in a field's value — not even near the actual payload — makes every one of the six checks silently report "no XSS found".

Meanwhile, a real browser decoding the same bytes as UTF-8 (the encoding Grav serves pages as) does not fail open: it substitutes the invalid byte with one U+FFFD replacement character and renders the surrounding markup completely normally. The <img ... onerror=...> tag is untouched structurally; the payload still fires.

Vulnerable Code

$patterns = [
    'on_events' => '#<(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?:[\s\x00-\x20\"\'\/]|"[^"]*"|\'[^\']*\')on\s*[a-z]+\s*=#iu',
    // ... five more, all with the /u modifier
];
foreach ($patterns as $name => $regex) {
    if (!empty($enabled_rules[$name])) {
        if (preg_match($regex, (string) $string) || preg_match($regex, $orig)) {
            return $name;
        }
        // ...
    }
}
return null; // reached even when the string contains <img onerror=...>,
             // as long as it also contains one invalid UTF-8 byte anywhere

Directly reproducible against the exact regex:

$regex = '#<(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?:[\s\x00-\x20\"\'\/]|"[^"]*"|\'[^\']*\')on\s*[a-z]+\s*=#iu';
var_dump(preg_match($regex, "<img src=x onerror=alert(1)>"));          // int(1)  -- caught
var_dump(preg_match($regex, "<img src=x \x80onerror=alert(1)>"));      // bool(false), preg_last_error()==4

Attack Scenario

  1. Attacker holds a page-edit ("publisher") account without super-admin rights.
  2. Sets page content to Hello world \x80<img src=x onerror=alert(document.cookie)> (a raw invalid UTF-8 byte, deliverable via any non-JSON submission path — e.g. the bundled Form plugin's multipart/urlencoded field, or any blueprint-validated field populated from a raw POST body — $_POST values are not UTF-8-validated by PHP).
  3. Validation::checkSafety() runs detectXss() on the value; every preg_match() call returns false, so detectXss() returns null ("no violation"). The payload saves unmodified.
  4. Any visitor (including a super-admin browsing the public site) loads the page; the browser renders the intact <img onerror=...> element, executing the attacker's JavaScript in the visitor's session.

Impact

  • Type: Stored XSS (CWE-79)
  • Auth required: Page-edit ("publisher") account, not super-admin
  • Consequence: Arbitrary JavaScript execution in any visitor's browser, including a super-admin who views the page — a cross-trust-boundary escalation from publisher to admin-equivalent action capability.

Recommended Fix

public static function detectXss($string, ?array $options = null): ?string
{
    if (null === $string || !is_string($string) || empty($string)) {
        return null;
    }

    // Fail closed: mb_check_encoding() validates the whole string up front
    // and returns a normal boolean — it never "fails open" the way a
    // /u-flagged preg_match() does on malformed input.
    if (!mb_check_encoding($string, 'UTF-8')) {
        return 'invalid_encoding';
    }

    // ... rest unchanged
}

Validation::checkSafety() only invokes detectXss() for accounts outside security.xss_whitelist (default admin.super), so this introduces no behavior change for whitelisted accounts.

Verification

Dynamically confirmed on grav 2.0.13: called the live Security::detectXss() directly (bootstrapped through Grav's own service container, not a standalone regex copy) — a clean payload was correctly flagged ("on_events"), the same payload plus one invalid UTF-8 byte returned NULL (bypass), and an ordinary safe string returned NULL as expected. Note: the JSON REST API (api plugin, the path Admin2's SPA uses to save pages) happens to reject raw invalid UTF-8 before it reaches detectXss(), because RFC 8259 requires JSON text to be valid UTF-8 and PHP's json_decode() enforces this — that's an incidental protection of the JSON layer, not a fix, and any non-JSON submission path (e.g. the bundled Form plugin's multipart/urlencoded fields) remains exposed. After applying the fix above, the same bypass payload correctly returns "invalid_encoding" (a violation), while an ordinary safe string still returns NULL (no regression).

A ready-to-apply fix branch is prepared locally against this repo's develop branch (based on the 2.0.13 tag); happy to push it to a private fork once one is available for this advisory.

Database specific
{
    "cwe_ids": [
        "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-17T20:44:39Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
}
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.14

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

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-q2j8-x8hf-63ch/GHSA-q2j8-x8hf-63ch.json"