XSS in contact tracking and page hits report
Description
Prior to this patch, a stored XSS vulnerability existed in the contact tracking and page hits report.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A stored cross-site scripting (XSS) vulnerability in Mautic's contact tracking and page hits report allows attackers to inject malicious scripts via user-controlled data.
Vulnerability
Overview
Prior to a specific patch, Mautic, an open-source marketing automation platform, contained a stored cross-site scripting (XSS) vulnerability in its contact tracking and page hits report [1][3]. The root cause involved unsafe handling of user-controlled input that was later rendered in the page title and modal dialogs. In the JavaScript code, the generatePageTitle function constructed the page title by concatenating user names or other dynamic data directly into a jQuery HTML string, which allowed encoded HTML entities to be decoded and executed [2][4]. Similarly, the loadAjaxModal function used .html() to set modal headers, bypassing proper output encoding [2][4].
Exploitation
An attacker could exploit this vulnerability by crafting a malicious payload (e.g., via a contact's company name or user name) that includes HTML or JavaScript code [4]. When the vulnerable report renders that data in the page title or modal dialogs, the injected script executes in the context of the victim's browser session [2][4]. No authentication or special privileges are required to trigger the stored XSS if the attacker can insert data into the system, as the vulnerability lies in how the application processes and displays such data. The attack vector is through the contact tracking endpoint (e.g., /mtc/event), where parameters like page_url containing encoded payloads are stored and later displayed [4].
Impact
Successful exploitation could allow an attacker to execute arbitrary JavaScript in the browsers of users who view the affected reports or dialogs [2][4]. This could lead to session hijacking, defacement, or redirection to malicious sites. Since the vulnerability is stored, any administrator or user accessing the dashboard or page hits report would be affected, potentially allowing lateral movement or data theft within the Mautic installation.
Mitigation
The vulnerability was fixed in a subsequent patch that sanitizes output by using .text() instead of .html() for dynamic content in page titles and modal headers, and by escaping output with $view->escape() in template files [2][4]. Users are strongly advised to update to the latest patched version of Mautic (4.4.x or later) to remediate this issue [1][2].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mautic/core-libPackagist | >= 1.0.0-beta4, < 4.4.13 | 4.4.13 |
mautic/core-libPackagist | >= 5.0.0-alpha, < 5.1.1 | 5.1.1 |
mautic/corePackagist | >= 1.0.0-beta4, < 4.4.13 | 4.4.13 |
mautic/corePackagist | >= 5.0.0-alpha, < 5.1.1 | 5.1.1 |
Affected products
3- ghsa-coords2 versions
>= 1.0.0-beta4, < 4.4.13+ 1 more
- (no CPE)range: >= 1.0.0-beta4, < 4.4.13
- (no CPE)range: >= 1.0.0-beta4, < 4.4.13
- Mautic/Mauticv5Range: >= 1.0.0-beta4
Patches
2550e33562d03Merge remote-tracking branch 'security/DPMMA-2855_mtc-contact-xss-v5' into 5.1
4 files changed · +48 −7
app/bundles/CoreBundle/Assets/js/1a.content.js+2 −2 modified@@ -204,8 +204,8 @@ Mautic.generatePageTitle = function(route){ currentModuleItem = mQuery('.page-header h3').text(); } - // Encoded entites are decoded by this process and can cause a XSS - currentModuleItem = mQuery('<div>'+currentModuleItem+'</div>').text(); + // Safely set the text content to prevent XSS + currentModuleItem = mQuery('<div>').text(currentModuleItem).html(); mQuery('title').html( currentModule[0].toUpperCase() + currentModule.slice(1) + ' | ' + currentModuleItem + ' | Mautic' ); } else {
app/bundles/CoreBundle/Assets/js/9.modals.js+2 −1 modified@@ -90,7 +90,8 @@ Mautic.loadAjaxModal = function (target, route, method, header, footer, preventD //move the modal to the body tag to get around positioned div issues element.one('show.bs.modal', function () { if (header) { - element.find(".modal-title").html(header); + // use text instead of html method to prevent XSS + element.find(".modal-title").text(header); } if (footer && footer != 'false') {
app/bundles/LeadBundle/Resources/views/Auditlog/details.html.twig+0 −4 modified@@ -24,7 +24,3 @@ {{ 'mautic.lead.audit.merged'|trans }} {% endif %} {% endif %} -<!-- -Event Type: "{{ event.eventType }}" -{{ event.details|json_encode(constant('JSON_PRETTY_PRINT'))|raw }} ---> \ No newline at end of file
app/bundles/PageBundle/Tests/Controller/PublicControllerFunctionalTest.php+44 −0 added@@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\PageBundle\Tests\Controller; + +use Mautic\CoreBundle\Test\MauticMysqlTestCase; +use PHPUnit\Framework\Assert; + +class PublicControllerFunctionalTest extends MauticMysqlTestCase +{ + public function testMtcEventCompanyXss(): void + { + $this->client->request('POST', '/mtc/event', [ + 'page_url' => 'https://example.com?Company=%3Cimg+src+onerror%3Dalert%28%27Company%27%29%3E', + ]); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + + $response = json_decode($clientResponse->getContent(), true); + + $this->client->request('GET', sprintf('/s/contacts/view/%d', $response['id'])); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + + $crawler = $this->client->request('GET', sprintf('/s/contacts/edit/%d', $response['id'])); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + + $buttonCrawlerNode = $crawler->selectButton('Save & Close'); + $form = $buttonCrawlerNode->form(); + $this->client->submit($form); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + } +}
629165ac905cMerge branch 'DPMMA-2855_mtc-contact-xss-v4' into 4.4
7 files changed · +57 −13
app/bundles/CoreBundle/Assets/js/1a.content.js+2 −2 modified@@ -203,8 +203,8 @@ Mautic.generatePageTitle = function(route){ currentModuleItem = mQuery('.page-header h3').text(); } - // Encoded entites are decoded by this process and can cause a XSS - currentModuleItem = mQuery('<div>'+currentModuleItem+'</div>').text(); + // Safely set the text content to prevent XSS + currentModuleItem = mQuery('<div>').text(currentModuleItem).html(); mQuery('title').html( currentModule[0].toUpperCase() + currentModule.slice(1) + ' | ' + currentModuleItem + ' | Mautic' ); } else {
app/bundles/CoreBundle/Assets/js/9.modals.js+2 −1 modified@@ -66,7 +66,8 @@ Mautic.loadAjaxModal = function (target, route, method, header, footer, preventD //move the modal to the body tag to get around positioned div issues mQuery(target).one('show.bs.modal', function () { if (header) { - mQuery(target + " .modal-title").html(header); + // use text instead of html method to prevent XSS + mQuery(target + " .modal-title").text(header); } if (footer && footer != 'false') {
app/bundles/DashboardBundle/Views/Dashboard/recentactivity.html.php+5 −5 modified@@ -27,23 +27,23 @@ <div class="media-body"> <?php if (isset($log['userId']) && $log['userId']) : ?> <a href="<?php echo $view['router']->path('mautic_user_action', ['objectAction' => 'edit', 'objectId' => $log['userId']]); ?>" data-toggle="ajax"> - <?php echo $log['userName']; ?> + <?php echo $view->escape($log['userName']); ?> </a> <?php elseif ($log['userName']) : ?> - <?php echo $log['userName']; ?> + <?php echo $view->escape($log['userName']); ?> <?php else: ?> <?php echo $view['translator']->trans('mautic.core.system'); ?> <?php endif; ?> <?php echo $view['translator']->trans('mautic.dashboard.'.$log['action'].'.past.tense'); ?> <?php if (!empty($log['route'])): ?> <a href="<?php echo $log['route']; ?>" data-toggle="ajax"> - <?php echo $log['objectName']; ?> + <?php echo $view->escape($log['objectName']); ?> </a> <?php elseif (!empty($log['objectName'])): ?> - <?php echo $log['objectName']; ?> + <?php echo $view->escape($log['objectName']); ?> <?php endif; ?> - <?php echo $log['object']; ?> + <?php echo $view->escape($log['object']); ?> <p class="fs-12 dark-sm"><small> <?php echo $view['date']->toFull($log['dateAdded']); ?></small></p> </div> </li>
app/bundles/LeadBundle/Controller/LeadController.php+3 −3 modified@@ -512,7 +512,7 @@ public function newAction() $this->addFlash( 'mautic.core.notice.created', [ - '%name%' => $identifier, + '%name%' => htmlspecialchars($identifier, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false), '%menu_link%' => 'mautic_contact_index', '%url%' => $this->generateUrl( 'mautic_contact_action', @@ -706,7 +706,7 @@ public function editAction($objectId, $ignorePost = false) $this->addFlash( 'mautic.core.notice.updated', [ - '%name%' => $identifier, + '%name%' => htmlspecialchars($identifier, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false), '%menu_link%' => 'mautic_contact_index', '%url%' => $this->generateUrl( 'mautic_contact_action', @@ -1114,7 +1114,7 @@ public function deleteAction($objectId) 'type' => 'notice', 'msg' => 'mautic.core.notice.deleted', 'msgVars' => [ - '%name%' => $identifier, + '%name%' => htmlspecialchars($identifier, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false), '%id%' => $objectId, ], ];
app/bundles/LeadBundle/Views/Auditlog/details.html.php+0 −1 modified@@ -99,4 +99,3 @@ break; } echo $text; -echo '<!-- '.PHP_EOL.json_encode($details, JSON_PRETTY_PRINT).PHP_EOL.' -->';
app/bundles/LeadBundle/Views/Lead/form.html.php+1 −1 modified@@ -11,7 +11,7 @@ $view->extend('MauticCoreBundle:Default:content.html.php'); $header = ($lead->getId()) ? $view['translator']->trans('mautic.lead.lead.header.edit', - ['%name%' => $view['translator']->trans($lead->getPrimaryIdentifier())]) : + ['%name%' => $view['translator']->trans($this->escape($lead->getPrimaryIdentifier()))]) : $view['translator']->trans('mautic.lead.lead.header.new'); $view['slots']->set('headerTitle', $header); $view['slots']->set('mauticContent', 'lead');
app/bundles/PageBundle/Tests/Controller/PublicControllerFunctionalTest.php+44 −0 added@@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\PageBundle\Tests\Controller; + +use Mautic\CoreBundle\Test\MauticMysqlTestCase; +use PHPUnit\Framework\Assert; + +class PublicControllerFunctionalTest extends MauticMysqlTestCase +{ + public function testMtcEventCompanyXss(): void + { + $this->client->request('POST', '/mtc/event', [ + 'page_url' => 'https://example.com?Company=%3Cimg+src+onerror%3Dalert%28%27Company%27%29%3E', + ]); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + + $response = json_decode($clientResponse->getContent(), true); + + $this->client->request('GET', sprintf('/s/contacts/view/%d', $response['id'])); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + + $crawler = $this->client->request('GET', sprintf('/s/contacts/edit/%d', $response['id'])); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + + $buttonCrawlerNode = $crawler->selectButton('Save & Close'); + $form = $buttonCrawlerNode->form(); + $this->client->submit($form); + $clientResponse = $this->client->getResponse(); + Assert::assertTrue($clientResponse->isOk()); + $content = $clientResponse->getContent(); + Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content); + } +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-xpc5-rr39-v8v2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-27917ghsaADVISORY
- github.com/mautic/mautic/commit/550e33562d03363f7592fa9354259787a23a1d98ghsaWEB
- github.com/mautic/mautic/commit/629165ac905c53bbb44feb5a6dbadb1dfd6d5564ghsaWEB
- github.com/mautic/mautic/security/advisories/GHSA-xpc5-rr39-v8v2ghsaWEB
News mentions
0No linked articles in our index yet.