Skip to content

data-mautic-disable-tracking="true" ignored when tracked URL is a path prefix of the do-not-track URL #16050

@adiux

Description

@adiux

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

  1. 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>
  1. Send the email to any contact (do NOT use Send Example, it will not track the links).
  2. 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('&amp;', '&', $search);
    $search  = str_replace('&', '(?:&|&amp;)', $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('&amp;', '&', $searchPattern);
    $searchPattern = str_replace('&', '(?:&|&amp;)', $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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues or PR's relating to bugsneeds-triageFor new issues/PRs that need to be triaged

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions