#25201·matomo

[Bug] # Unbounded loop in `setConsentGiven()` causes renderer OOM crash when `mtm_consent_removed` cannot be deleted

Author: CircleCodeCreated Sep 1, 2026Updated Sep 3, 2026
LabelsPotential Bugtriaged

What happened?

Tracker.setConsentGiven() drains consentRequestsQueue with a for loop whose bound is re-evaluated on every iteration, while the callee appends to that same array. If the consent-removed cookie cannot be deleted, the loop never terminates and the browser tab crashes.

The loop

// js/piwik.js — this.setConsentGiven
deleteCookie(CONSENT_REMOVED_COOKIE_NAME, configCookiePath, configCookieDomain);

var i, requestType;
for (i = 0; i < consentRequestsQueue.length; i++) {
    requestType = typeof consentRequestsQueue[i][0];
    if (requestType === 'string') {
        sendRequest(consentRequestsQueue[i][0], configTrackerPause, consentRequestsQueue[i][1]);
    } else if (requestType === 'object') {
        sendBulkRequest(consentRequestsQueue[i][0], configTrackerPause);
    }
}
consentRequestsQueue = [];

sendRequest() pushes back onto the array being iterated:

// js/piwik.js — sendRequest
function sendRequest(request, delay, callback) {
    refreshConsentStatus();
    if (!configHasConsent) {
        consentRequestsQueue.push([request, callback]);   // <-- grows the loop bound
        return;
    }
    ...
}
// js/piwik.js — refreshConsentStatus
function refreshConsentStatus() {
    if (getCookie(CONSENT_REMOVED_COOKIE_NAME)) {
        configHasConsent = false;
    } else if (getCookie(CONSENT_COOKIE_NAME)) {
        configHasConsent = true;
    }
}

So when the deleteCookie() on the first line fails to remove the cookie, each iteration re-reads mtm_consent_removed, still finds it, sets configHasConsent = false, and re-queues the request. i advances by one and consentRequestsQueue.length grows by one, so the condition never becomes false.

The loop is synchronous and never yields. It ends only when the renderer exhausts its heap and the tab is killed.

Why the delete can fail

deleteCookie uses only the currently configured scope:

function deleteCookie(cookieName, path, domain) {
    setCookie(cookieName, '', -129600000, path, domain);
}

Cookie identity is (name, domain, path). Deletion requires the domain and path attributes to match those the cookie was written with. If mtm_consent_removed exists under a different scope than the tracker's current configCookieDomain / configCookiePath, the delete writes a second, already-expired cookie beside the original and the original survives — so getCookie(CONSENT_REMOVED_COOKIE_NAME) keeps returning a value indefinitely.

Ways this arises in practice:

  1. A changed setCookieDomain between deploys, leaving orphaned cookies from the previous configuration that the new configuration cannot delete. These persist until expiry — and forgetConsentGiven() sets a 30-year expiry by default, so effectively forever.
  2. Any configCookiePath change, including the implicit default derived from the current page path.
  3. Two tracker instances with different cookie domains on the same page — less common in general, but this was our case, and it fails within a single page load. Tracker A calls setCookieDomain('*.example.com'); tracker B does not call it at all. forgetConsentGiven() on A writes domain=.example.com; B's setConsentGiven() attempts a host-only delete and fails.

In all cases the misconfiguration is on the site's side. The consequence — a hard renderer crash rather than degraded tracking — comes from the loop.

Observed consequences

  • Tab renders, then becomes unresponsive during load, then crashes.
  • Nothing is logged. No exception, no console warning.
  • CPU sits at roughly 20%, not 100%, because ~90% of the loop's wall time is spent blocked in the synchronous document.cookie IPC. This defeats the usual "look for the pegged core" heuristic.
  • The DevTools debugger cannot break in and the Performance panel cannot finish processing, because the thread never reaches an interruptible point. We could only capture this via chrome://tracing, which collects out of process.
  • Once a visitor has an undeletable cookie, the site is permanently broken for them. It cannot self-heal.

Because the failure is silent and unrecoverable, affected visitors are likely to close the tab rather than report anything — so this may already be occurring undetected on other installations.

What should happen?

A stale or unexpectedly-scoped mtm_consent_removed cookie should, at worst, cause tracking requests to be withheld. It should never be able to hang or crash the page. Specifically:

  • setConsentGiven() should complete in bounded time regardless of cookie state.
  • A failed cookie deletion should be surfaced to the developer, not silent.
  • Requests that cannot be sent should be retried on a later consent event, not cause an unbounded retry within the current call.

1. Make the drain loop bounded (primary)

Detach the queue before iterating, so the callee cannot extend the bound:

var pending = consentRequestsQueue;
consentRequestsQueue = [];
for (i = 0; i < pending.length; i++) {
    ...
}

Requests re-queued by sendRequest() then land in a fresh array and are retried on the next consent event instead of extending the current pass. This change alone converts the crash into a no-op.

2. Trust the explicit call over the cookie

setConsentGiven() is an explicit statement that consent exists. Having refreshConsentStatus() immediately override configHasConsent back to false from a cookie the caller just asked to have deleted is surprising. Consider skipping the refresh for requests drained by setConsentGiven(), or setting a flag for the duration of the drain.

3. Verify deletion and warn

After deleteCookie(CONSENT_REMOVED_COOKIE_NAME, ...), re-read the cookie; if it survives, console.warn naming the attempted domain and path. That single log line would have saved us a great deal of time, and would let site owners find the misconfiguration themselves.

4. Reduce document.cookie reads

refreshConsentStatus() runs on every sendRequest(), and each read is a blocking IPC whose cost scales with cookie-jar size. A short-lived cache of the parsed cookie string, invalidated on write, would cut this substantially even in non-pathological cases.

5. Consider a broader delete

Optionally attempt deletion across the current host and its registrable-domain variants, so scope changes do not leave permanently undeletable orphans.

How can this be reproduced?

change setCookieDomain between 2 deployments

Matomo version

Saas

PHP version

No response

Server operating system

No response

What browsers are you seeing the problem on?

No response

Computer operating system

No response

Relevant log output

Environment

  • Matomo Cloud tracker, matomo.js 156 KB (bundling FormAnalytics, MediaAnalytics, AbTesting; Heatmap/SessionRecording not enabled)
  • Two tracker instances via Google Tag Manager
  • Axeptio CMP, pushing consent to GTM on every page load via replayInitialGtmEvents
  • Nuxt 3 SSR application
  • Reproduced in Brave 152 and Chrome 152 on macOS 26.6.2 (arm64)
  • Cookie jar 5,271 bytes at time of crash (unusually large, which made the per-iteration cost worse and the crash faster)
  • Chrome heap limit ~4 GB

What we measured on the real crash

From a chrome://tracing capture with v8.cpu_profiler (Performance panel cannot be used — the thread never reaches an interruptible point):

getCookie — 6,629 ms self time, 40,530 samples, 66.9% of all CPU, in a single profile node CookieJar::Cookies — 5,947 ms across 13,463 calls (~0.44 ms each), i.e. ~90% of the loop's wall time is blocked in synchronous IPC One contiguous execution, 3.26 s → 9.92 s, zero gaps above 4.5 ms — a single setConsentGiven() invocation that never yields

Validations

--

Note: bug reported assisted by Claude after deep anaysis