Mautic Series: 6.0.x series
Mautic installed version: 6.0.8
Way of installing: I downloaded a release from https://www.mautic.org/mautic-releases
PHP version: 8.1.0
Browsers: Not relevant
What happened?
When an email body contains both a tracked link and a do-not-track link (data-mautic-disable-tracking="true") where the tracked URL is a path prefix of the do-not-track URL (same domain), the data-mautic-disable-tracking="true" attribute is silently ignored and the do-not-track link's href is corrupted into a broken tracking redirect URL.
Example input:
<!-- tracked -->
<a href="https://example.com/">Homepage</a>
<!-- should NOT be tracked -->
<a href="https://example.com/contact/" data-mautic-disable-tracking="true">Contact</a>
Expected output: the contact link is left untouched:
<a href="https://example.com/contact/" data-mautic-disable-tracking="true">Contact</a>
Actual output: the contact link is corrupted into a broken tracking URL with the path suffix appended:
<a href="https://mautic.example.com/r/abc123?ct=...&utm_content=.../contact/" data-mautic-disable-tracking="true">Contact</a>
Root cause: TrackableModel::prepareContentWithTrackableTokens (app/bundles/PageBundle/Model/TrackableModel.php, around line 305) iterates over tracked URLs and uses preg_replace with this pattern:
'/<(.*?) href=(["\'])' . $search . '(.*?)\2(.*?)>/i'
The lazy (.*?) after $search matches any path suffix, so when $search = 'https://example\.com/', the pattern also matches href="https://example.com/contact/" — capturing contact/ as the suffix. The replacement produces href="{trackable=X}contact/" which after token substitution becomes the tracking redirect URL with /contact/ appended.
This method does not consult $this->doNotTrack at all. The do-not-track list is populated correctly by extractTrackables, but prepareContentWithTrackableTokens runs independently and has no knowledge of it.
The existing uksort (longest URL first) guard in second_pass only protects when both URLs are tracked. When one URL is in doNotTrack it is absent from second_pass, so the length-sort provides no protection.
How to reproduce
- Create a new email in Mautic with the following HTML body:
<a href="https://example.com/">Visit our website</a>
<a href="https://example.com/contact/" data-mautic-disable-tracking="true">Contact us (should not be tracked)</a>
- Send the email to any contact (do NOT use Send Example, it will not track the links).
- Inspect the received HTML source — the contact link will contain a tracking redirect URL instead of
https://example.com/contact/.
The bug triggers whenever the tracked URL is a string prefix of the do-not-track URL. Common real-world examples:
| Tracked |
Do-not-track (broken) |
https://example.com/ |
https://example.com/contact/ |
https://example.com/blog |
https://example.com/blog/my-post/ |
https://example.com/de |
https://example.com/de/impressum/ |
https://example.com/products |
https://example.com/products/detail/ |
Relevant log output
None — the corruption happens silently with no warning or error logged.
Proposed fix
In app/bundles/PageBundle/Model/TrackableModel.php, method prepareContentWithTrackableTokens (~line 305), replace preg_replace with preg_replace_callback and check isInDoNotTrack on the full reconstructed href before replacing:
// Before
foreach ($this->contentReplacements['second_pass'] as $search => $replace) {
$search = preg_quote($search, '/');
$search = str_replace('&', '&', $search);
$search = str_replace('&', '(?:&|&)', $search);
$content = preg_replace(
'/<(.*?) href=(["\'])'.$search.'(.*?)\\2(.*?)>/i',
'<$1 href=$2'.$replace.'$3$2$4>',
$content
);
}
// After
foreach ($this->contentReplacements['second_pass'] as $search => $replace) {
$searchPattern = preg_quote($search, '/');
$searchPattern = str_replace('&', '&', $searchPattern);
$searchPattern = str_replace('&', '(?:&|&)', $searchPattern);
$content = preg_replace_callback(
'/<(.*?) href=(["\'])'.$searchPattern.'(.*?)\\2(.*?)>/i',
function (array $m) use ($search, $replace): string {
// $m[3] is any suffix after the tracked URL base (e.g. "contact/" when
// search="https://example.com/" and href="https://example.com/contact/").
// Skip replacement when the full href belongs to the do-not-track list.
if ($this->isInDoNotTrack($search.$m[3])) {
return $m[0];
}
return '<'.$m[1].' href='.$m[2].$replace.$m[3].$m[2].$m[4].'>';
},
$content
);
}
Code of Conduct: I confirm that I have read and agree to follow this project's Code of Conduct.
Care about this issue? Want to get it resolved sooner? If you are a
member of Mautic, you can add some funds to the
Bounties Project so that the person who completes this task can claim those funds once it is merged by a member of the core team! Read the docs
here.
Mautic Series: 6.0.x series
Mautic installed version: 6.0.8
Way of installing: I downloaded a release from https://www.mautic.org/mautic-releases
PHP version: 8.1.0
Browsers: Not relevant
What happened?
When an email body contains both a tracked link and a do-not-track link (
data-mautic-disable-tracking="true") where the tracked URL is a path prefix of the do-not-track URL (same domain), thedata-mautic-disable-tracking="true"attribute is silently ignored and the do-not-track link'shrefis corrupted into a broken tracking redirect URL.Example input:
Expected output: the contact link is left untouched:
Actual output: the contact link is corrupted into a broken tracking URL with the path suffix appended:
Root cause:
TrackableModel::prepareContentWithTrackableTokens(app/bundles/PageBundle/Model/TrackableModel.php, around line 305) iterates over tracked URLs and usespreg_replacewith this pattern:The lazy
(.*?)after$searchmatches any path suffix, so when$search = 'https://example\.com/', the pattern also matcheshref="https://example.com/contact/"— capturingcontact/as the suffix. The replacement produceshref="{trackable=X}contact/"which after token substitution becomes the tracking redirect URL with/contact/appended.This method does not consult
$this->doNotTrackat all. The do-not-track list is populated correctly byextractTrackables, butprepareContentWithTrackableTokensruns independently and has no knowledge of it.The existing
uksort(longest URL first) guard insecond_passonly protects when both URLs are tracked. When one URL is indoNotTrackit is absent fromsecond_pass, so the length-sort provides no protection.How to reproduce
https://example.com/contact/.The bug triggers whenever the tracked URL is a string prefix of the do-not-track URL. Common real-world examples:
https://example.com/https://example.com/contact/https://example.com/bloghttps://example.com/blog/my-post/https://example.com/dehttps://example.com/de/impressum/https://example.com/productshttps://example.com/products/detail/Relevant log output
None — the corruption happens silently with no warning or error logged.
Proposed fix
In
app/bundles/PageBundle/Model/TrackableModel.php, methodprepareContentWithTrackableTokens(~line 305), replacepreg_replacewithpreg_replace_callbackand checkisInDoNotTrackon the full reconstructed href before replacing:Code of Conduct: I confirm that I have read and agree to follow this project's Code of Conduct.
Care about this issue? Want to get it resolved sooner? If you are a member of Mautic, you can add some funds to the Bounties Project so that the person who completes this task can claim those funds once it is merged by a member of the core team! Read the docs here.