Every CVE Search/ Get API key

CVE-2026-45270

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

8.7 HighFix available

CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, 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. Version 0.31.9.0 patches the issue.

Attack profile

How easy it is to exploit

Attack vector
Network
Complexity
Low
Privileges needed
Low
User interaction
Required

What an attacker can affect

Confidentiality
High
Integrity
High
Availability
None

Exploit likelihood

0.26%Up 0.2 points since Jun 12, 2026
90%50%10%1%

What each source reports

CVE List Score used 8.7

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

CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, 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. Version 0.31.9.0 patches the issue.

Record
CVE-2026-45270
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Weakness
CWE-79
Updated
2026-11-02
EUVDEUVD-2026-45951 8.7

CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, 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. Version 0.31.9.0 patches the issue.

Record
EUVD-2026-45951
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Updated
2026-11-02
GitHub AdvisoriesGHSA-gqr2-7hcg-rchf 8.7

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

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 protected]' -d 'password=AuthorPass1!'

Step…

Record
GHSA-gqr2-7hcg-rchf
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Weakness
CWE-79
Updated
2026-11-02
NVD 8.7

CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, 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. Version 0.31.9.0 patches the issue.

Record
CVE-2026-45270
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Weakness
CWE-79
Updated
2026-11-02
OSV 8.7

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

CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, 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. Version 0.31.9.0 patches the issue.

Record
CVE-2026-45270
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Weakness
CWE-79
Updated
2026-11-02
OSVGHSA-gqr2-7hcg-rchf 8.7

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

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 protected]' -d 'password=AuthorPass1!'

Step…

Record
GHSA-gqr2-7hcg-rchf
CVSS 3.1
8.7 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
Weakness
CWE-79
Updated
2026-11-02

Affected products

Affected packages

Affected packages
PackageAffectedFixed
ci4-cms-erp/ci4ms
Packagist
through 0.31.8.0
0.31.9.0
ci4-cms-erp/ci4ms
Packagist
before 0.31.9.0, plus listed versions
0.21.00.21.10.21.20.21.30.21.3.1
Show 45 moreShow less
0.21.3.20.21.3.30.21.3.40.21.3.50.21.3.60.21.3.70.23.0.00.23.0.10.23.0.20.23.1.00.24.0.00.24.0.160.24.0.180.24.0.190.24.0.200.24.0.270.24.0.420.24.0.450.24.0.600.25.0.00.25.0.10.25.0.20.25.0.300.25.0.390.25.0.430.25.1.00.25.2.00.25.3.00.26.0.00.26.1.00.26.2.00.26.3.00.26.3.10.26.3.20.26.3.30.26.3.40.27.0.00.28.0.00.28.3.00.28.4.00.28.5.00.28.6.00.31.0.00.31.1.00.31.2.0
0.31.9.0

History

History
RecordedChangedSources
first indexedCVE List, EUVD, GitHub Advisories, NVD, OSV

Dates are when Every CVE recorded each change. Upstream dates may differ.

In short

CVE-2026-45270 is a high vulnerability (CVSS 8.7) in Ci4-cms-erp Ci4ms, published May 18, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.26%, the 18th percentile. Fixed in ci4-cms-erp/ci4ms 0.31.9.0.

References

This page doesn't list everything

Left out here: 7 versions and the rest of 2 source descriptions. The JSON record has them.

MoveEnter Open