GHSA-q2j8-x8hf-63ch: XSS

Published Sep 17, 2026
·
Updated

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.xsswhitelist 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, pregmatch() does not "skip" that byte or report "no match" — it returns false for the entire call, with preglasterror() === PREGBADUTF8ERROR. detectXss() only checks truthiness (if (pregmatch(...) || pregmatch(...))), 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 php $patterns = [ 'onevents' => '#<(?:"[^"]"|\'[^\']\'|[^>"\'])?(?:[\s\x00-\x20\"\'\/]|"[^"]"|\'[^\']\')on\s[a-z]+\s=#iu', // ... five more, all with the /u modifier ]; foreach ($patterns as $name => $regex) { if (!empty($enabledrules[$name])) { if (pregmatch($regex, (string) $string) || pregmatch($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: php $regex = '#<(?:"[^"]"|\'[^\']\'|[^>"\'])?(?:[\s\x00-\x20\"\'\/]|"[^"]"|\'[^\']\')on\s[a-z]+\s=#iu'; vardump(pregmatch($regex, "<img src=x onerror=alert(1)>")); // int(1) -- caught vardump(pregmatch($regex, "<img src=x \x80onerror=alert(1)>")); // bool(false), preglasterror()==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 pregmatch() 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 php public static function detectXss($string, ?array $options = null): ?string { if (null === $string || !isstring($string) || empty($string)) { return null; }

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

// ... rest unchanged } Validation::checkSafety() only invokes detectXss() for accounts outside security.xsswhitelist (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 ("onevents"), 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 jsondecode() 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 "invalidencoding" (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.

Affected Software

1 affected componentFixes available
composer/getgrav/grav<2.0.14
2.0.14

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/getgrav/grav to a version that resolves this vulnerability.

    Fixed in 2.0.14
  2. Upgrade

    Upgrade getgrav/grav core to a version that resolves this vulnerability.

    Fixed in 2.0.13
  3. Configuration

    Update Grav's Security::detectXss()/validation XSS checks to validate the entire input up-front with mb_check_encoding($string, 'UTF-8') and fail closed by returning 'invalid_encoding' when the string contains any invalid UTF-8 bytes; ensure the regex evaluation path does not treat preg_match() false as 'no XSS found'.

    Grav/Common/Security.php (detectXss -> detectXssInEditorContent / Validation::checkSafety) XSS detection fail-closed UTF-8 validation using mb_check_encoding = Use mb_check_encoding($string, 'UTF-8') and return 'invalid_encoding' when encoding is invalid (instead of allowing preg_match() to return false/bypass)
  4. Operational

    Verify remediation by testing detectXss() on inputs: (1) a payload string that should be flagged (e.g., "on_events" should return the rule name), (2) the same payload with a single invalid UTF-8 byte appended/inserted should return "invalid_encoding", and (3) a safe UTF-8 string should return null ('no violation').

Event History

Sep 17, 2026
Advisory Published
via GitHub·08:44 PM
Data Sourced
via GitHub·08:44 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which users and content paths are exposed?

Accounts that are not in security.xss_whitelist and can save blueprint fields are subject to the save-time safety check. This includes the page content field, so lower-privileged page editors may be able to store affected content.

2

What does an attacker need to exploit this issue?

The attacker needs a low-privileged account able to save a relevant blueprint field and must submit malformed input that causes the UTF-8-mode PCRE checks to fail open. Exploitation also requires a user to view content containing the stored payload.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203