CVE-2026-45138
CI4MS: Stored XSS in Blog 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 custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts. 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
- Low
- Integrity
- Low
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
5.4
CI4MS: Stored XSS in Blog 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 custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts. Version 0.31.9.0 patches the issue.
- Record
CVE-2026-45138- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
EUVDEUVD-2026-45875
5.4
CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, the custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts. Version 0.31.9.0 patches the issue.
- Record
EUVD-2026-45875- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-2m69-jmvh-6chr
5.4
CI4MS: Stored XSS in Blog Content via Broken `html_purify` Validation Rule
Summary
The custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts.
Details
Root cause: by-reference mutation never propagates
Modules\Backend\Validation\CustomRules::html_purify declares its first argument by reference:
// modules/Backend/Validation/CustomRules.php:54-73
public function html_purify(?string &$str = null, ?string &$error = null): bool
{
if (empty(trim((string)$str))) return true;
if (!class_exists('\HTMLPurifier')) { $error = lang('Backend.htmlPurifierNotFound'); return false; }
$clean = self::sanitizeHtml($str);
$str = $clean; // <-- mutates only the local $value in CI4's validator
self::$cleanCache[md5((string)$str)] = $clean; // <-- key is md5(CLEAN), getClean() looks up md5(ORIGINAL)
return true;
}
CI4's validator invokes the rule via a local variable $value it created from a copy of $this->data:
// vendor/codeigniter4/framework/system/Validation/Validation.php:204-211
foreach ($values as $dotField => $value) { // local $value
$this->processRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);
}
// Validation.php:343-345
$passed = ($param === null)
? $set->{$rule}($value, $error) // <-- $value is the local var
: $set->{$rule}($value, $param, $data, $error, $field);
The reference mutation modifies that local $value only; $this->data, $_POST, and getValidated() keep the raw payload. The optional getClean($original) cache lookup in CustomRules.php:85-93 also fails because the cache was keyed on md5(clean) rather than md5(original).
Sink: raw POST is persisted and rendered unescaped
The Blog controller takes $_POST['lang'] verbatim, runs it through validation (which always returns true for html_purify), and writes it to the database with no further filtering:
// modules/Blog/Controllers/Blog.php:94-125 (Blog::new)
$langsPost = $this->request->getPost('lang'); // raw, unsanitized
...
if ($this->validate($valData) == false) return redirect()->...; // html_purify returns true
...
foreach ($langsPost as $lanCode => $lanData) {
$this->commonModel->create('blog_langs', [
'blog_id' => $insertID,
'lang' => $lanCode,
'title' => trim(strip_tags($lanData['title'])),
'seflink' => trim(strip_tags($lanData['seflink'])),
'content' => $lanData['content'], // <-- raw HTML stored
...
]);
}
The same pattern is used in Blog::edit at modules/Blog/Controllers/Blog.php:178 and :201.
The public blog post template echoes the field with no escaping:
// app/Views/templates/default/blog/post.php:51
<section class="mb-5" id="ci4ms-content">
<?php echo $infos->content ?>
</section>
The view is reached through App\Controllers\Home::post* (Home.php:238), which is an unauthenticated public route.
Trust boundary
Backend routes (modules/Blog/Config/Routes.php) are protected by backendGuard + Shield role checks, requiring blogs.create / blogs.update. These are delegated content-editor roles, not equivalent to superadmin: an editor cannot install plugins, run SQL, or access the file editor. Stored XSS therefore lets a low-privilege editor escalate by hijacking a superadmin session when the admin previews or edits the post (frontend /blog/<slug> is the executing surface; admin browsers visit it routinely). Independent of admin…
- Record
GHSA-2m69-jmvh-6chr- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
NVD
5.4
CI4MS is a CodeIgniter 4-based content management system skeleton. Prior to version 0.31.9.0, the custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts. Version 0.31.9.0 patches the issue.
- Record
CVE-2026-45138- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
OSV
5.4
CI4MS: Stored XSS in Blog 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 custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts. Version 0.31.9.0 patches the issue.
- Record
CVE-2026-45138- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
OSVGHSA-2m69-jmvh-6chr
5.4
CI4MS: Stored XSS in Blog Content via Broken `html_purify` Validation Rule
Summary
The custom html_purify validation rule used to sanitize blog post bodies relies on by-reference mutation (?string &$str), but CodeIgniter 4's validator passes a local copy of the value, so the sanitized text is silently discarded. The Blog controller writes $lanData['content'] directly into blog_langs.content, and the public template echoes it without escaping — yielding stored XSS executable in any visitor's browser, including the superadmin when previewing or editing posts.
Details
Root cause: by-reference mutation never propagates
Modules\Backend\Validation\CustomRules::html_purify declares its first argument by reference:
// modules/Backend/Validation/CustomRules.php:54-73
public function html_purify(?string &$str = null, ?string &$error = null): bool
{
if (empty(trim((string)$str))) return true;
if (!class_exists('\HTMLPurifier')) { $error = lang('Backend.htmlPurifierNotFound'); return false; }
$clean = self::sanitizeHtml($str);
$str = $clean; // <-- mutates only the local $value in CI4's validator
self::$cleanCache[md5((string)$str)] = $clean; // <-- key is md5(CLEAN), getClean() looks up md5(ORIGINAL)
return true;
}
CI4's validator invokes the rule via a local variable $value it created from a copy of $this->data:
// vendor/codeigniter4/framework/system/Validation/Validation.php:204-211
foreach ($values as $dotField => $value) { // local $value
$this->processRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);
}
// Validation.php:343-345
$passed = ($param === null)
? $set->{$rule}($value, $error) // <-- $value is the local var
: $set->{$rule}($value, $param, $data, $error, $field);
The reference mutation modifies that local $value only; $this->data, $_POST, and getValidated() keep the raw payload. The optional getClean($original) cache lookup in CustomRules.php:85-93 also fails because the cache was keyed on md5(clean) rather than md5(original).
Sink: raw POST is persisted and rendered unescaped
The Blog controller takes $_POST['lang'] verbatim, runs it through validation (which always returns true for html_purify), and writes it to the database with no further filtering:
// modules/Blog/Controllers/Blog.php:94-125 (Blog::new)
$langsPost = $this->request->getPost('lang'); // raw, unsanitized
...
if ($this->validate($valData) == false) return redirect()->...; // html_purify returns true
...
foreach ($langsPost as $lanCode => $lanData) {
$this->commonModel->create('blog_langs', [
'blog_id' => $insertID,
'lang' => $lanCode,
'title' => trim(strip_tags($lanData['title'])),
'seflink' => trim(strip_tags($lanData['seflink'])),
'content' => $lanData['content'], // <-- raw HTML stored
...
]);
}
The same pattern is used in Blog::edit at modules/Blog/Controllers/Blog.php:178 and :201.
The public blog post template echoes the field with no escaping:
// app/Views/templates/default/blog/post.php:51
<section class="mb-5" id="ci4ms-content">
<?php echo $infos->content ?>
</section>
The view is reached through App\Controllers\Home::post* (Home.php:238), which is an unauthenticated public route.
Trust boundary
Backend routes (modules/Blog/Config/Routes.php) are protected by backendGuard + Shield role checks, requiring blogs.create / blogs.update. These are delegated content-editor roles, not equivalent to superadmin: an editor cannot install plugins, run SQL, or access the file editor. Stored XSS therefore lets a low-privilege editor escalate by hijacking a superadmin session when the admin previews or edits the post (frontend /blog/<slug> is the executing surface; admin browsers visit it routinely). Independent of admin…
- Record
GHSA-2m69-jmvh-6chr- CVSS 3.1
- 5.4 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
ci4-cms-erp/ci4msPackagist |
through 0.31.8.0 | 0.31.9.0 |
ci4-cms-erp/ci4msPackagist |
before 0.31.9.0, plus listed versions 0.21.00.21.10.21.20.21.30.21.3.1 Show 45 moreShow less0.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
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-45138 is a medium vulnerability (CVSS 5.4) 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.18%, the 7th percentile. Fixed in ci4-cms-erp/ci4ms 0.31.9.0.
References
github.com/ci4-cms-erp/ci4ms/security/advisories/GHSA-2m69-jmvh-6chr
github.com/ci4-cms-erp/ci4ms/releases/tag/0.31.9.0
github.com/advisories/GHSA-2m69-jmvh-6chr
github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45138.json
nvd.nist.gov/vuln/detail/CVE-2026-45138
github.com/ci4-cms-erp/ci4ms
Left out here: 7 versions and the rest of 2 source descriptions. The JSON record has them.