Every CVE Search/ Get API key

CVE-2026-45138

CI4MS: Stored XSS in Blog Content via Broken `html_purify` Validation Rule

5.4 MediumFix available

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

0.18%Up 0.1 points since Jun 12, 2026
90%50%10%1%

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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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

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-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

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