VYPR
\" \\\n -d 'isActive=1' \\\n -d 'categories[]=1' \\\n -d 'author=1' \\\n -d 'created_at=01.01.2026 10:00:00' \\\n -d 'csrf_token_name='\n```\n\n2. The validator returns success (`html_purify` reports `true`), and the row is written to `blog_langs` with `content` = `` verbatim.\n\n3. Visit the public post URL `https://target/blog/poc-xss`. The injected `\n```\n\nThat 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.\n\n## Impact\n\n- **Stored XSS reachable by any account with `blogs.create` or `blogs.update`** (delegated content-editor permission), executed in the browser of:\n - every anonymous public visitor that loads the affected blog post,\n - the superadmin and other backend reviewers when they open or preview the post.\n- 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.\n- 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.\n- The `getClean()` cache fallback intended as a backstop is also non-functional (key mismatch between `md5(clean)` writer and `md5(original)` reader).\n\n## Recommended Fix\n\n1. 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.\n\n Minimal, immediate fix in the Blog controller — apply to both `new` and `edit`:\n\n ```php\n // modules/Blog/Controllers/Blog.php (Blog::new, ~line 123 and Blog::edit, ~line 201)\n use Modules\\Backend\\Validation\\CustomRules;\n ...\n $this->commonModel->create('blog_langs', [\n 'blog_id' => $insertID,\n 'lang' => $lanCode,\n 'title' => trim(strip_tags($lanData['title'])),\n 'seflink' => trim(strip_tags($lanData['seflink'])),\n 'content' => CustomRules::sanitizeHtml((string)($lanData['content'] ?? '')),\n 'seo' => !empty($seoData) ? $seoData : '',\n ]);\n ```\n\n 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).\n\n2. Fix the cache key bug so `getClean()` actually works as a defense-in-depth backstop:\n\n ```php\n // modules/Backend/Validation/CustomRules.php\n public function html_purify(?string &$str = null, ?string &$error = null): bool\n {\n if (empty(trim((string)$str))) return true;\n if (!class_exists('\\HTMLPurifier')) { $error = lang('Backend.htmlPurifierNotFound'); return false; }\n $original = (string)$str;\n $clean = self::sanitizeHtml($original);\n self::$cleanCache[md5($original)] = $clean; // key on ORIGINAL, before reassignment\n $str = $clean; // best-effort; CI4 will drop this\n return true;\n }\n ```\n\n3. Document explicitly in `CustomRules` that `html_purify` is *not* a sanitizer — it returns `true` unconditionally on any HTMLPurifier-installed environment — and that callers MUST use `CustomRules::sanitizeHtml(...)` (or `CustomRules::getClean($original)` after the cache fix) on `$_POST` data before storage.\n\n4. Defense in depth: escape `$infos->content` at output where feasible (e.g., `app/Views/templates/default/blog/post.php:51`), or pipe the stored value through `CustomRules::sanitizeHtml()` on read for templates that are expected to render rich HTML — guaranteeing safety even if a future caller forgets the sanitizer.","additionalType":"https://schema.org/SoftwareApplication","sameAs":["https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-45138"]},"keywords":"CVE-2026-45138, moderate, CWE-79, Ci4 CMS ERP Ci4ms","mentions":[{"@type":"SoftwareApplication","name":"Ci4ms","applicationCategory":"SecurityApplication","publisher":{"@type":"Organization","name":"Ci4 CMS ERP"}}],"isAccessibleForFree":true},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://portal.vyprsec.ai/"},{"@type":"ListItem","position":2,"name":"CVEs","item":"https://portal.vyprsec.ai/cves"},{"@type":"ListItem","position":3,"name":"CVE-2026-45138","item":"https://portal.vyprsec.ai/cves/CVE-2026-45138"}]}]}
Moderate severityGHSA Advisory· Published May 18, 2026· Updated May 18, 2026

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

CVE-2026-45138

Description

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.

  1. 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]=" \
  -d 'isActive=1' \
  -d 'categories[]=1' \
  -d 'author=1' \
  -d 'created_at=01.01.2026 10:00:00' \
  -d 'csrf_token_name='
  1. The validator returns success (html_purify reports true), and the row is written to blog_langs with content = `` verbatim.

3. Visit the public post URL https://target/blog/poc-xss. The injected ` 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 1. 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: ``php

// 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). 2. Fix the cache key bug so getClean() actually works as a defense-in-depth backstop: ``php

// modules/Backend/Validation/CustomRules.php 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; } $original = (string)$str; $clean = self::sanitizeHtml($original); self::$cleanCache[md5($original)] = $clean; // key on ORIGINAL, before reassignment $str = $clean; // best-effort; CI4 will drop this return true; } `` 3. Document explicitly in CustomRules that html_purify is *not* a sanitizer — it returns true unconditionally on any HTMLPurifier-installed environment — and that callers MUST use CustomRules::sanitizeHtml(...) (or CustomRules::getClean($original) after the cache fix) on $_POST data before storage. 4. Defense in depth: escape $infos->content at output where feasible (e.g., app/Views/templates/default/blog/post.php:51), or pipe the stored value through CustomRules::sanitizeHtml()` on read for templates that are expected to render rich HTML — guaranteeing safety even if a future caller forgets the sanitizer.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
ci4-cms-erp/ci4msPackagist
< 0.31.9.00.31.9.0

Affected products

1

Patches

Vulnerability mechanics

References

3

News mentions

1