[Bug] # Unbounded loop in `setConsentGiven()` causes renderer OOM crash when `mtm_consent_removed` cannot be deleted
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:
- A changed
setCookieDomainbetween deploys, leaving orphaned cookies from the previous configuration that the new configuration cannot delete. These persist until expiry — andforgetConsentGiven()sets a 30-year expiry by default, so effectively forever. - Any
configCookiePathchange, including the implicit default derived from the current page path. - 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 writesdomain=.example.com; B'ssetConsentGiven()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.cookieIPC. 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.js156 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
- Read our Contributing Guidelines.
- Follow our Security Policy.
- Check that there isn't already an issue that reports the same bug to avoid creating duplicates.
- The provided steps to reproduce is a minimal reproducible of the Bug.
--
Note: bug reported assisted by Claude after deep anaysis
Source: matomo-org/matomo