/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const THREE_DAYS_MS = 3 * 24 * 60 * 1000; const lazy = {}; XPCOMUtils.defineLazyServiceGetter( lazy, "gClassifier", "@mozilla.org/url-classifier/dbservice;1", "nsIURIClassifier" ); XPCOMUtils.defineLazyServiceGetter( lazy, "gStorageActivityService", "@mozilla.org/storage/activity-service;1", "nsIStorageActivityService" ); ChromeUtils.defineLazyGetter(lazy, "gClassifierFeature", () => { return lazy.gClassifier.getFeatureByName("tracking-annotation"); }); ChromeUtils.defineLazyGetter(lazy, "logger", () => { return console.createInstance({ prefix: "*** PurgeTrackerService:", maxLogLevelPref: "privacy.purge_trackers.logging.level", }); }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gConsiderEntityList", "privacy.purge_trackers.consider_entity_list" ); export function PurgeTrackerService() {} PurgeTrackerService.prototype = { classID: Components.ID("{90d1fd17-2018-4e16-b73c-a04a26fa6dd4}"), QueryInterface: ChromeUtils.generateQI(["nsIPurgeTrackerService"]), // Purging is batched for cookies to avoid clearing too much data // at once. This flag tells us whether this is the first daily iteration. _firstIteration: true, // We can only know asynchronously if a host is matched by the tracking // protection list, so we cache the result for faster future lookups. _trackingState: new Map(), observe(aSubject, aTopic) { switch (aTopic) { case "idle-daily": // only allow one idle-daily listener to trigger until the list has been fully parsed. Services.obs.removeObserver(this, "idle-daily"); this.purgeTrackingCookieJars(); break; case "profile-after-change": Services.obs.addObserver(this, "idle-daily"); break; } }, async isTracker(principal) { if (principal.isNullPrincipal || principal.isSystemPrincipal) { return false; } let host; try { host = principal.asciiHost; } catch (error) { return false; } if (!this._trackingState.has(host)) { // Temporarily set to false to avoid doing several lookups if a site has // several subframes on the same domain. this._trackingState.set(host, false); await new Promise(resolve => { try { lazy.gClassifier.asyncClassifyLocalWithFeatures( principal.URI, [lazy.gClassifierFeature], Ci.nsIUrlClassifierFeature.blocklist, list => { if (list.length) { this._trackingState.set(host, true); } resolve(); } ); } catch { // Error in asyncClassifyLocalWithFeatures, it is not a tracker. this._trackingState.set(host, false); resolve(); } }); } return this._trackingState.get(host); }, isAllowedThirdParty(firstPartyOriginNoSuffix, thirdPartyHost) { let uri = Services.io.newURI( `${firstPartyOriginNoSuffix}/?resource=${thirdPartyHost}` ); lazy.logger.debug(`Checking entity list state for`, uri.spec); return new Promise(resolve => { try { lazy.gClassifier.asyncClassifyLocalWithFeatures( uri, [lazy.gClassifierFeature], Ci.nsIUrlClassifierFeature.entitylist, list => { let sameList = !!list.length; lazy.logger.debug(`Is ${uri.spec} on the entity list?`, sameList); resolve(sameList); } ); } catch { resolve(false); } }); }, async maybePurgePrincipal(principal) { let origin = principal.origin; lazy.logger.debug(`Maybe purging ${origin}.`); // First, check if any site with that base domain had received // user interaction in the last N days. let hasInteraction = this._baseDomainsWithInteraction.has( principal.baseDomain ); // Exit early unless we want to see if we're dealing with a tracker, // for telemetry. if (hasInteraction && !Services.telemetry.canRecordPrereleaseData) { lazy.logger.debug(`${origin} has user interaction, exiting.`); return; } // Second, confirm that we're looking at a tracker. let isTracker = await this.isTracker(principal); if (!isTracker) { lazy.logger.debug(`${origin} is not a tracker, exiting.`); return; } if (hasInteraction) { let expireTimeMs = this._baseDomainsWithInteraction.get( principal.baseDomain ); // Collect how much longer the user interaction will be valid for, in hours. let timeRemaining = Math.floor( (expireTimeMs - Date.now()) / 1000 / 60 / 60 / 24 ); let permissionAgeHistogram = Services.telemetry.getHistogramById( "COOKIE_PURGING_TRACKERS_USER_INTERACTION_REMAINING_DAYS" ); permissionAgeHistogram.add(timeRemaining); this._telemetryData.notPurged.add(principal.baseDomain); lazy.logger.debug(`${origin} is a tracker with interaction, exiting.`); return; } let isAllowedThirdParty = false; if ( lazy.gConsiderEntityList || Services.telemetry.canRecordPrereleaseData ) { for (let firstPartyPrincipal of this._principalsWithInteraction) { if ( await this.isAllowedThirdParty( firstPartyPrincipal.originNoSuffix, principal.asciiHost ) ) { isAllowedThirdParty = true; break; } } } if (isAllowedThirdParty && lazy.gConsiderEntityList) { lazy.logger.debug( `${origin} has interaction on the entity list, exiting.` ); return; } lazy.logger.log("Deleting data from:", origin); await new Promise(resolve => { Services.clearData.deleteDataFromPrincipal( principal, false, Ci.nsIClearDataService.CLEAR_ALL_CACHES | Ci.nsIClearDataService.CLEAR_COOKIES | Ci.nsIClearDataService.CLEAR_DOM_STORAGES | Ci.nsIClearDataService.CLEAR_CLIENT_AUTH_REMEMBER_SERVICE | Ci.nsIClearDataService.CLEAR_EME | Ci.nsIClearDataService.CLEAR_MEDIA_DEVICES | Ci.nsIClearDataService.CLEAR_STORAGE_ACCESS | Ci.nsIClearDataService.CLEAR_AUTH_TOKENS | Ci.nsIClearDataService.CLEAR_AUTH_CACHE | Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD, resolve ); }); lazy.logger.log(`Data deleted from:`, origin); this._telemetryData.purged.add(principal.baseDomain); }, resetPurgeList() { // We've reached the end of the cookies. // Restore the idle-daily listener so it will purge again tomorrow. Services.obs.addObserver(this, "idle-daily"); // Set the date to 0 so we will start at the beginning of the list next time. Services.prefs.setStringPref( "privacy.purge_trackers.date_in_cookie_database", "0" ); }, submitTelemetry() { let { purged, notPurged, durationIntervals } = this._telemetryData; let now = Date.now(); let lastPurge = Number( Services.prefs.getStringPref("privacy.purge_trackers.last_purge", now) ); let intervalHistogram = Services.telemetry.getHistogramById( "COOKIE_PURGING_INTERVAL_HOURS" ); let hoursBetween = Math.floor((now - lastPurge) / 1000 / 60 / 60); intervalHistogram.add(hoursBetween); Services.prefs.setStringPref( "privacy.purge_trackers.last_purge", now.toString() ); let purgedHistogram = Services.telemetry.getHistogramById( "COOKIE_PURGING_ORIGINS_PURGED" ); purgedHistogram.add(purged.size); let notPurgedHistogram = Services.telemetry.getHistogramById( "COOKIE_PURGING_TRACKERS_WITH_USER_INTERACTION" ); notPurgedHistogram.add(notPurged.size); let duration = durationIntervals .map(([start, end]) => end - start) .reduce((acc, cur) => acc + cur, 0); let durationHistogram = Services.telemetry.getHistogramById( "COOKIE_PURGING_DURATION_MS" ); durationHistogram.add(duration); }, /* * Checks Cookie Permission a given 2 principals * if either prinicpial cookie permissions are to prevent purging * the function would return true */ checkCookiePermissions(httpsPrincipal, httpPrincipal) { let httpsCookiePermission; let httpCookiePermission; if (httpPrincipal) { httpCookiePermission = Services.perms.testPermissionFromPrincipal( httpPrincipal, "cookie" ); } if (httpsPrincipal) { httpsCookiePermission = Services.perms.testPermissionFromPrincipal( httpsPrincipal, "cookie" ); } if ( httpCookiePermission == Ci.nsICookiePermission.ACCESS_ALLOW || httpsCookiePermission == Ci.nsICookiePermission.ACCESS_ALLOW ) { return true; } return false; }, /** * This loops through all cookies saved in the database and checks if they are a tracking cookie, if it is it checks * that they have an interaction permission which is still valid. If the Permission is not valid we delete all data * associated with the site that owns that cookie. */ async purgeTrackingCookieJars() { let purgeEnabled = Services.prefs.getBoolPref( "privacy.purge_trackers.enabled", false ); let sanitizeOnShutdownEnabled = Services.prefs.getBoolPref( "privacy.sanitize.sanitizeOnShutdown", false ); let clearHistoryOnShutdown = Services.prefs.getBoolPref( "privacy.clearOnShutdown.history", false ); let clearSiteSettingsOnShutdown = Services.prefs.getBoolPref( "privacy.clearOnShutdown.siteSettings", false ); // This is a hotfix for bug 1672394. It avoids purging if the user has enabled mechanisms // that regularly clear the storageAccessAPI permission, such as clearing history or // "site settings" (permissions) on shutdown. if ( sanitizeOnShutdownEnabled && (clearHistoryOnShutdown || clearSiteSettingsOnShutdown) ) { lazy.logger.log( ` Purging canceled because interaction permissions are cleared on shutdown. sanitizeOnShutdownEnabled: ${sanitizeOnShutdownEnabled}, clearHistoryOnShutdown: ${clearHistoryOnShutdown}, clearSiteSettingsOnShutdown: ${clearSiteSettingsOnShutdown}, ` ); this.resetPurgeList(); return; } // Purge cookie jars for following cookie behaviors. // * BEHAVIOR_REJECT_FOREIGN // * BEHAVIOR_LIMIT_FOREIGN // * BEHAVIOR_REJECT_TRACKER (ETP) // * BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN (dFPI) let cookieBehavior = Services.cookies.getCookieBehavior(false); let activeWithCookieBehavior = cookieBehavior == Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN || cookieBehavior == Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN || cookieBehavior == Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER || cookieBehavior == Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN; if (!activeWithCookieBehavior || !purgeEnabled) { lazy.logger.log( `returning early, activeWithCookieBehavior: ${activeWithCookieBehavior}, purgeEnabled: ${purgeEnabled}` ); this.resetPurgeList(); return; } lazy.logger.log("Purging trackers enabled, beginning batch."); // How many cookies to loop through in each batch before we quit const MAX_PURGE_COUNT = Services.prefs.getIntPref( "privacy.purge_trackers.max_purge_count", 100 ); if (this._firstIteration) { this._telemetryData = { durationIntervals: [], purged: new Set(), notPurged: new Set(), }; this._baseDomainsWithInteraction = new Map(); this._principalsWithInteraction = []; for (let perm of Services.perms.getAllWithTypePrefix( "storageAccessAPI" )) { this._baseDomainsWithInteraction.set( perm.principal.baseDomain, perm.expireTime ); this._principalsWithInteraction.push(perm.principal); } } // Record how long this iteration took for telemetry. // This is a tuple of start and end time, the second // part will be added at the end of this function. let duration = [Cu.now()]; /** * We record the creationTime of the last cookie we looked at and * start from there next time. This way even if new cookies are added or old ones are deleted we * have a reliable way of finding our spot. **/ let saved_date = Services.prefs.getStringPref( "privacy.purge_trackers.date_in_cookie_database", "0" ); let maybeClearPrincipals = new Map(); // TODO We only need the host name and creationTime, this gives too much info. See bug 1610373. let cookies = Services.cookies.getCookiesSince(saved_date); cookies = cookies.slice(0, MAX_PURGE_COUNT); for (let cookie of cookies) { let httpPrincipal; let httpsPrincipal; let origin = "http://" + cookie.rawHost + ChromeUtils.originAttributesToSuffix(cookie.originAttributes); try { httpPrincipal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( origin ); } catch (e) { lazy.logger.error( `Creating principal from origin ${origin} led to error ${e}.` ); } origin = "https://" + cookie.rawHost + ChromeUtils.originAttributesToSuffix(cookie.originAttributes); try { httpsPrincipal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( origin ); } catch (e) { lazy.logger.error( `Creating principal from origin ${origin} led to error ${e}.` ); } // Checking to see if the Cookie Permissions is set to prevent Cookie from // purging for either the HTTPS or HTTP conncetions let purgeCheck = this.checkCookiePermissions( httpsPrincipal, httpPrincipal ); if (httpPrincipal && !purgeCheck) { maybeClearPrincipals.set(httpPrincipal.origin, httpPrincipal); } if (httpsPrincipal && !purgeCheck) { maybeClearPrincipals.set(httpsPrincipal.origin, httpsPrincipal); } saved_date = cookie.creationTime; } // We only consider recently active storage and don't batch it, // so only do this in the first iteration. if (this._firstIteration) { let startDate = Date.now() - THREE_DAYS_MS; let storagePrincipals = lazy.gStorageActivityService.getActiveOrigins( startDate * 1000, Date.now() * 1000 ); for (let principal of storagePrincipals.enumerate()) { // Check Principal Domains Cookie Permissions for both Schemes // To ensure it does not bypass the cookie permissions set by the user if (principal.schemeIs("https") || principal.schemeIs("http")) { let otherURI; let otherPrincipal; if (principal.schemeIs("https")) { otherURI = principal.URI.mutate().setScheme("http").finalize(); } else if (principal.schemeIs("http")) { otherURI = principal.URI.mutate().setScheme("https").finalize(); } try { otherPrincipal = Services.scriptSecurityManager.createContentPrincipal( otherURI, {} ); } catch (e) { lazy.logger.error( `Creating principal from URI ${otherURI} led to error ${e}.` ); } if (!this.checkCookiePermissions(principal, otherPrincipal)) { maybeClearPrincipals.set(principal.origin, principal); } } else { maybeClearPrincipals.set(principal.origin, principal); } } } for (let principal of maybeClearPrincipals.values()) { await this.maybePurgePrincipal(principal); } Services.prefs.setStringPref( "privacy.purge_trackers.date_in_cookie_database", saved_date ); duration.push(Cu.now()); this._telemetryData.durationIntervals.push(duration); // We've reached the end, no need to repeat again until next idle-daily. if (!cookies.length || cookies.length < 100) { lazy.logger.log( "All cookie purging finished, resetting list until tomorrow." ); this.resetPurgeList(); this.submitTelemetry(); this._firstIteration = true; return; } lazy.logger.log("Batch finished, queueing next batch."); this._firstIteration = false; Services.tm.idleDispatchToMainThread(() => { this.purgeTrackingCookieJars(); }); }, };