{"id":"CVE-2026-75828","aliases":["GHSA-vfmf-q6x9-cw96"],"url":"https://o3.security/vulnerability/CVE-2026-75828","summary":"Grav before 2.0.15 Stored XSS via detectXss() Quote Bypass","details":"## Affected versions and vulnerable location\n\n- Confirmed on grav core at `78ebfc1` (tag 2.0.13).\n- Detector: `system/src/Grav/Common/Security.php:290`, the `on_events` regex, run via `patternMatches()` (`:315-330`).\n- The `on_events` pattern at HEAD:\n  `#<(?:\"[^\"]*\"|'[^']*'|[^>\"'])*?(?:[\\s\\x00-\\x20\"'/]|\"[^\"]*\"|'[^']*')on\\s*[a-z]+\\s*=#iu`\n- 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`).\n\n## Root cause (distinct from GHSA-269c)\n\nGHSA-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.\n\n## Proof (executed)\n\nThe detector was replicated verbatim (the `on_events` regex plus `patternMatches`) with the shipped `system/config/security.yaml` defaults and run under PHP. Observed:\n\n```text\nbaseline  <img src=x onerror=alert(1)>            => blocked (on_events)\nGHSA-269c <img src=x title=\">\" onerror=alert(1)>  => blocked (on_events)   # prior fix works\nBYPASS A  <img src=x\" onerror=alert(1)>           => PASSES (no XSS detected)\nBYPASS B  <img title=x\" onerror=alert(1)>         => PASSES (no XSS detected)\nBYPASS C  <a href=x\" onmouseover=alert(1)>x</a>   => PASSES (no XSS detected)\nBYPASS D  <img src=x' onerror=alert(1)>           => PASSES (no XSS detected)\nBYPASS E  <div id=x\" onmouseover=alert(1)>hover</div> => PASSES (no XSS detected)\n```\n\nBrowser 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`.\n\n## Reachability\n\n`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.\n\nAuth required: an authenticated content editor with page/form edit rights but WITHOUT `admin.super`.\n\n## Suggested fix\n\nMake 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.\n\n## Severity and CVSS reasoning\n\nSuggested severity: High (matches GHSA-269c and the other stored-XSS advisories in this codebase).\n\nSuggested 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).\n\n- `PR:L`: a non-super editor account.\n- `UI:R`: a visitor renders the page (for `onerror` the image simply loads/fails automatically).\n- `S:C/C:H/I:H`: script in the site origin against every visitor, including admins viewing the content.\n\n## How I found it and a note on tooling\n\nI 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.","published":"2026-08-18T11:19:41.001Z","modified":"2026-09-10T03:30:35.683606761Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"getgrav/grav","fixedVersion":"2.0.15"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/75xxx/CVE-2026-75828.json"},{"type":"ADVISORY","url":"https://github.com/getgrav/grav/security/advisories/GHSA-vfmf-q6x9-cw96"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-75828"},{"type":"ADVISORY","url":"https://www.vulncheck.com/advisories/grav-before-stored-xss-via-detectxss-quote-bypass"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T03:30:35.683606761Z"}}