Skip to content

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

Description

@CircleCode

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Potential BugSomething that might be a bug, but needs validation and confirmation it can be reproduced.triaged

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions