tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SiteDataManager.sys.mjs (21171B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineLazyGetter(lazy, "gStringBundle", function () {
      8  return Services.strings.createBundle(
      9    "chrome://browser/locale/siteData.properties"
     10  );
     11 });
     12 
     13 ChromeUtils.defineLazyGetter(lazy, "gBrandBundle", function () {
     14  return Services.strings.createBundle(
     15    "chrome://branding/locale/brand.properties"
     16  );
     17 });
     18 
     19 ChromeUtils.defineESModuleGetters(lazy, {
     20  Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
     21 });
     22 
     23 export var SiteDataManager = {
     24  // A Map of sites and their disk usage according to Quota Manager.
     25  // Key is base domain (group sites based on base domain across scheme, port,
     26  // origin attributes) or host if the entry does not have a base domain.
     27  // Value is one object holding:
     28  //   - baseDomainOrHost: Same as key.
     29  //   - principals: instances of nsIPrincipal (only when the site has
     30  //     quota storage).
     31  //   - persisted: the persistent-storage status.
     32  //   - quotaUsage: the usage of indexedDB and localStorage.
     33  //   - containersData: a map containing cookiesBlocked,lastAccessed and quotaUsage by userContextID.
     34  _sites: new Map(),
     35 
     36  _getCacheSizeObserver: null,
     37 
     38  _getCacheSizePromise: null,
     39 
     40  _getQuotaUsagePromise: null,
     41 
     42  _quotaUsageRequest: null,
     43 
     44  /**
     45   *  Retrieve the latest site data and store it in SiteDataManager.
     46   *
     47   *  Updating site data is a *very* expensive operation. This method exists so that
     48   *  consumers can manually decide when to update, most methods on SiteDataManager
     49   *  will not trigger updates automatically.
     50   *
     51   *  It is *highly discouraged* to await on this function to finish before showing UI.
     52   *  Either trigger the update some time before the data is needed or use the
     53   *  entryUpdatedCallback parameter to update the UI async.
     54   *
     55   * @param {entryUpdatedCallback} a function to be called whenever a site is added or
     56   *        updated. This can be used to e.g. fill a UI that lists sites without
     57   *        blocking on the entire update to finish.
     58   * @returns a Promise that resolves when updating is done.
     59   */
     60  async updateSites(entryUpdatedCallback) {
     61    Services.obs.notifyObservers(null, "sitedatamanager:updating-sites");
     62    // Clear old data and requests first
     63    this._sites.clear();
     64    this._getAllCookies(entryUpdatedCallback);
     65    await this._getQuotaUsage(entryUpdatedCallback);
     66    Services.obs.notifyObservers(null, "sitedatamanager:sites-updated");
     67  },
     68 
     69  /**
     70   * Get the base domain of a host on a best-effort basis.
     71   *
     72   * @param {string} host - Host to convert.
     73   * @returns {string} Computed base domain. If the base domain cannot be
     74   * determined, because the host is an IP address or does not have enough
     75   * domain levels we will return the original host. This includes the empty
     76   * string.
     77   * @throws {Error} Throws for unexpected conversion errors from eTLD service.
     78   */
     79  getBaseDomainFromHost(host) {
     80    let result = host;
     81    try {
     82      result = Services.eTLD.getBaseDomainFromHost(host);
     83    } catch (e) {
     84      if (
     85        e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
     86        e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
     87      ) {
     88        // For these 2 expected errors, just take the host as the result.
     89        // - NS_ERROR_HOST_IS_IP_ADDRESS: the host is in ipv4/ipv6.
     90        // - NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS: not enough domain parts to extract.
     91        result = host;
     92      } else {
     93        throw e;
     94      }
     95    }
     96    return result;
     97  },
     98 
     99  _getOrInsertSite(baseDomainOrHost) {
    100    let site = this._sites.get(baseDomainOrHost);
    101    if (!site) {
    102      site = {
    103        baseDomainOrHost,
    104        cookies: [],
    105        persisted: false,
    106        quotaUsage: 0,
    107        lastAccessed: 0,
    108        principals: [],
    109      };
    110      this._sites.set(baseDomainOrHost, site);
    111    }
    112    return site;
    113  },
    114 
    115  /**
    116   * Insert site with specific params into the SiteDataManager
    117   * Currently used for testing purposes
    118   *
    119   * @param {string} baseDomainOrHost
    120   * @param {object} Site info params
    121   * @returns {object} site object
    122   */
    123  _testInsertSite(
    124    baseDomainOrHost,
    125    {
    126      cookies = [],
    127      persisted = false,
    128      quotaUsage = 0,
    129      lastAccessed = 0,
    130      principals = [],
    131    }
    132  ) {
    133    let site = {
    134      baseDomainOrHost,
    135      cookies,
    136      persisted,
    137      quotaUsage,
    138      lastAccessed,
    139      principals,
    140    };
    141    this._sites.set(baseDomainOrHost, site);
    142 
    143    return site;
    144  },
    145 
    146  _getOrInsertContainersData(site, userContextId) {
    147    if (!site.containersData) {
    148      site.containersData = new Map();
    149    }
    150 
    151    let containerData = site.containersData.get(userContextId);
    152    if (!containerData) {
    153      containerData = {
    154        cookiesBlocked: 0,
    155        lastAccessed: new Date(0),
    156        quotaUsage: 0,
    157      };
    158      site.containersData.set(userContextId, containerData);
    159    }
    160    return containerData;
    161  },
    162 
    163  /**
    164   * Retrieves the amount of space currently used by disk cache.
    165   *
    166   * You can use DownloadUtils.convertByteUnits to convert this to
    167   * a user-understandable size/unit combination.
    168   *
    169   * @returns a Promise that resolves with the cache size on disk in bytes.
    170   */
    171  getCacheSize() {
    172    if (this._getCacheSizePromise) {
    173      return this._getCacheSizePromise;
    174    }
    175 
    176    this._getCacheSizePromise = new Promise((resolve, reject) => {
    177      // Needs to root the observer since cache service keeps only a weak reference.
    178      this._getCacheSizeObserver = {
    179        onNetworkCacheDiskConsumption: consumption => {
    180          resolve(consumption);
    181          this._getCacheSizePromise = null;
    182          this._getCacheSizeObserver = null;
    183        },
    184 
    185        QueryInterface: ChromeUtils.generateQI([
    186          "nsICacheStorageConsumptionObserver",
    187          "nsISupportsWeakReference",
    188        ]),
    189      };
    190 
    191      try {
    192        Services.cache2.asyncGetDiskConsumption(this._getCacheSizeObserver);
    193      } catch (e) {
    194        reject(e);
    195        this._getCacheSizePromise = null;
    196        this._getCacheSizeObserver = null;
    197      }
    198    });
    199 
    200    return this._getCacheSizePromise;
    201  },
    202 
    203  _getQuotaUsage(entryUpdatedCallback) {
    204    this._cancelGetQuotaUsage();
    205    this._getQuotaUsagePromise = new Promise(resolve => {
    206      let onUsageResult = request => {
    207        if (request.resultCode == Cr.NS_OK) {
    208          let items = request.result;
    209          for (let item of items) {
    210            if (!item.persisted && item.usage <= 0) {
    211              // An non-persistent-storage site with 0 byte quota usage is redundant for us so skip it.
    212              continue;
    213            }
    214            let principal =
    215              Services.scriptSecurityManager.createContentPrincipalFromOrigin(
    216                item.origin
    217              );
    218            if (principal.schemeIs("http") || principal.schemeIs("https")) {
    219              // Group dom storage by first party. If an entry is partitioned
    220              // the first party site will be in the partitionKey, instead of
    221              // the principal baseDomain.
    222              let pkBaseDomain;
    223              try {
    224                pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
    225                  principal.originAttributes.partitionKey
    226                );
    227              } catch (e) {
    228                console.error(e);
    229              }
    230              let site = this._getOrInsertSite(
    231                pkBaseDomain || principal.baseDomain
    232              );
    233              // Assume 3 sites:
    234              //   - Site A (not persisted): https://www.foo.com
    235              //   - Site B (not persisted): https://www.foo.com^userContextId=2
    236              //   - Site C (persisted):     https://www.foo.com:1234
    237              //     Although only C is persisted, grouping by base domain, as a
    238              //     result, we still mark as persisted here under this base
    239              //     domain group.
    240              if (item.persisted) {
    241                site.persisted = true;
    242              }
    243              if (site.lastAccessed < item.lastAccessed) {
    244                site.lastAccessed = item.lastAccessed;
    245              }
    246              if (Number.isInteger(principal.userContextId)) {
    247                let containerData = this._getOrInsertContainersData(
    248                  site,
    249                  principal.userContextId
    250                );
    251                containerData.quotaUsage = item.usage;
    252                let itemTime = item.lastAccessed / 1000;
    253                if (containerData.lastAccessed.getTime() < itemTime) {
    254                  containerData.lastAccessed.setTime(itemTime);
    255                }
    256              }
    257              site.principals.push(principal);
    258              site.quotaUsage += item.usage;
    259              if (entryUpdatedCallback) {
    260                entryUpdatedCallback(principal.baseDomain, site);
    261              }
    262            }
    263          }
    264        }
    265        resolve();
    266      };
    267      // XXX: The work of integrating localStorage into Quota Manager is in progress.
    268      //      After the bug 742822 and 1286798 landed, localStorage usage will be included.
    269      //      So currently only get indexedDB usage.
    270      this._quotaUsageRequest = Services.qms.getUsage(onUsageResult);
    271    });
    272    return this._getQuotaUsagePromise;
    273  },
    274 
    275  _getAllCookies(entryUpdatedCallback) {
    276    for (let cookie of Services.cookies.cookies) {
    277      // Group cookies by first party. If a cookie is partitioned the
    278      // partitionKey will contain the first party site, instead of the host
    279      // field.
    280      let pkBaseDomain;
    281      try {
    282        pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
    283          cookie.originAttributes.partitionKey
    284        );
    285      } catch (e) {
    286        console.error(e);
    287      }
    288      let baseDomainOrHost =
    289        pkBaseDomain || this.getBaseDomainFromHost(cookie.rawHost);
    290      let site = this._getOrInsertSite(baseDomainOrHost);
    291      if (entryUpdatedCallback) {
    292        entryUpdatedCallback(baseDomainOrHost, site);
    293      }
    294      site.cookies.push(cookie);
    295      if (Number.isInteger(cookie.originAttributes.userContextId)) {
    296        let containerData = this._getOrInsertContainersData(
    297          site,
    298          cookie.originAttributes.userContextId
    299        );
    300        containerData.cookiesBlocked += 1;
    301        let cookieTime = cookie.lastAccessed / 1000;
    302        if (containerData.lastAccessed.getTime() < cookieTime) {
    303          containerData.lastAccessed.setTime(cookieTime);
    304        }
    305      }
    306      if (site.lastAccessed < cookie.lastAccessed) {
    307        site.lastAccessed = cookie.lastAccessed;
    308      }
    309    }
    310  },
    311 
    312  _cancelGetQuotaUsage() {
    313    if (this._quotaUsageRequest) {
    314      this._quotaUsageRequest.cancel();
    315      this._quotaUsageRequest = null;
    316    }
    317  },
    318 
    319  /**
    320   * Checks if the site with the provided ASCII host is using any site data at all.
    321   * This will check for:
    322   *   - Cookies (incl. subdomains)
    323   *   - Quota Usage
    324   * in that order. This function is meant to be fast, and thus will
    325   * end searching and return true once the first trace of site data is found.
    326   *
    327   * @param {string} the ASCII host to check
    328   * @returns {boolean} whether the site has any data associated with it
    329   */
    330  async hasSiteData(asciiHost) {
    331    if (Services.cookies.countCookiesFromHost(asciiHost)) {
    332      return true;
    333    }
    334 
    335    let hasQuota = await new Promise(resolve => {
    336      Services.qms.getUsage(request => {
    337        if (request.resultCode != Cr.NS_OK) {
    338          resolve(false);
    339          return;
    340        }
    341 
    342        for (let item of request.result) {
    343          if (!item.persisted && item.usage <= 0) {
    344            continue;
    345          }
    346 
    347          let principal =
    348            Services.scriptSecurityManager.createContentPrincipalFromOrigin(
    349              item.origin
    350            );
    351          if (principal.asciiHost == asciiHost) {
    352            resolve(true);
    353            return;
    354          }
    355        }
    356 
    357        resolve(false);
    358      });
    359    });
    360 
    361    if (hasQuota) {
    362      return true;
    363    }
    364 
    365    return false;
    366  },
    367 
    368  /**
    369   * Fetches total quota usage
    370   * This method assumes that siteDataManager.updateSites has been called externally
    371   *
    372   * @returns total quota usage
    373   */
    374  getTotalUsage() {
    375    return this._getQuotaUsagePromise.then(() => {
    376      let usage = 0;
    377      for (let site of this._sites.values()) {
    378        usage += site.quotaUsage;
    379      }
    380      return usage;
    381    });
    382  },
    383 
    384  /**
    385   *
    386   * Fetch quota usage for all time ranges to display in the clear data dialog.
    387   * This method assumes that SiteDataManager.updateSites has been called externally
    388   *
    389   * @param {string[]} timeSpanArr - Array of timespan options to get quota usage
    390   *              from Sanitizer, e.g. ["TIMESPAN_HOUR", "TIMESPAN_2HOURS"]
    391   * @returns {object} bytes used for each timespan
    392   */
    393  async getQuotaUsageForTimeRanges(timeSpanArr) {
    394    let usage = {};
    395    await this._getQuotaUsagePromise;
    396 
    397    for (let timespan of timeSpanArr) {
    398      usage[timespan] = 0;
    399    }
    400 
    401    let timeNow = Date.now();
    402    for (let site of this._sites.values()) {
    403      let lastAccessed = new Date(site.lastAccessed / 1000);
    404      for (let timeSpan of timeSpanArr) {
    405        let compareTime = new Date(
    406          timeNow - lazy.Sanitizer.timeSpanMsMap[timeSpan]
    407        );
    408 
    409        if (timeSpan === "TIMESPAN_EVERYTHING") {
    410          usage[timeSpan] += site.quotaUsage;
    411        } else if (lastAccessed >= compareTime) {
    412          usage[timeSpan] += site.quotaUsage;
    413        }
    414      }
    415    }
    416    return usage;
    417  },
    418 
    419  /**
    420   * Gets all sites that are currently storing site data. Entries are grouped by
    421   * parent base domain if applicable. For example "foo.example.com",
    422   * "example.com" and "bar.example.com" will have one entry with the baseDomain
    423   * "example.com".
    424   * A base domain entry will represent all data of its storage jar. The storage
    425   * jar holds all first party data of the domain as well as any third party
    426   * data partitioned under the domain. Additionally we will add data which
    427   * belongs to the domain but is part of other domains storage jars . That is
    428   * data third-party partitioned under other domains.
    429   * Sites which cannot be associated with a base domain, for example IP hosts,
    430   * are not grouped.
    431   *
    432   * The list is not automatically up-to-date. You need to call
    433   * {@link updateSites} before you can use this method for the first time (and
    434   * whenever you want to get an updated set of list.)
    435   *
    436   * @returns {Promise} Promise that resolves with the list of all sites.
    437   */
    438  async getSites() {
    439    await this._getQuotaUsagePromise;
    440 
    441    return Array.from(this._sites.values()).map(site => ({
    442      baseDomain: site.baseDomainOrHost,
    443      cookies: site.cookies,
    444      usage: site.quotaUsage,
    445      containersData: site.containersData,
    446      persisted: site.persisted,
    447      lastAccessed: new Date(site.lastAccessed / 1000),
    448    }));
    449  },
    450 
    451  /**
    452   * Get site, which stores data, by base domain or host.
    453   *
    454   * The list is not automatically up-to-date. You need to call
    455   * {@link updateSites} before you can use this method for the first time (and
    456   * whenever you want to get an updated set of list.)
    457   *
    458   * @param {string} baseDomainOrHost - Base domain or host of the site to get.
    459   *
    460   * @returns {Promise<object | null>} Promise that resolves with the site object
    461   * or null if no site with given base domain or host stores data.
    462   */
    463  async getSite(baseDomainOrHost) {
    464    let baseDomain = this.getBaseDomainFromHost(baseDomainOrHost);
    465 
    466    let site = this._sites.get(baseDomain);
    467    if (!site) {
    468      return null;
    469    }
    470    return {
    471      baseDomain: site.baseDomainOrHost,
    472      cookies: site.cookies,
    473      usage: site.quotaUsage,
    474      containersData: site.containersData,
    475      persisted: site.persisted,
    476      lastAccessed: new Date(site.lastAccessed / 1000),
    477    };
    478  },
    479 
    480  _removePermission(site) {
    481    let removals = new Set();
    482    for (let principal of site.principals) {
    483      let { originNoSuffix } = principal;
    484      if (removals.has(originNoSuffix)) {
    485        // In case of encountering
    486        //   - https://www.foo.com
    487        //   - https://www.foo.com^userContextId=2
    488        // because setting/removing permission is across OAs already so skip the same origin without suffix
    489        continue;
    490      }
    491      removals.add(originNoSuffix);
    492      Services.perms.removeFromPrincipal(principal, "persistent-storage");
    493    }
    494  },
    495 
    496  _removeCookies(site) {
    497    for (let cookie of site.cookies) {
    498      Services.cookies.remove(
    499        cookie.host,
    500        cookie.name,
    501        cookie.path,
    502        cookie.originAttributes
    503      );
    504    }
    505    site.cookies = [];
    506  },
    507 
    508  /**
    509   * Removes all site data and caches for the specified list of domains and
    510   * hosts. This includes data of subdomains belonging to the domains or hosts
    511   * and partitioned storage. Data is cleared per storage jar, which means if we
    512   * clear "example.com", we will also clear third parties embedded on
    513   * "example.com". Additionally we will clear all data of "example.com" (as a
    514   * third party) from other jars.
    515   *
    516   * @param {string|string[]} domainsOrHosts - List of domains and hosts or
    517   * single domain or host to remove.
    518   * @returns {Promise} Promise that resolves when data is removed and the site
    519   * data manager has been updated.
    520   */
    521  async remove(domainsOrHosts) {
    522    if (domainsOrHosts == null) {
    523      throw new Error("domainsOrHosts is required.");
    524    }
    525    // Allow the caller to pass a single base domain or host.
    526    if (!Array.isArray(domainsOrHosts)) {
    527      domainsOrHosts = [domainsOrHosts];
    528    }
    529 
    530    let promises = [];
    531    for (let domainOrHost of domainsOrHosts) {
    532      promises.push(
    533        new Promise(function (resolve) {
    534          const { clearData } = Services;
    535          if (domainOrHost) {
    536            let schemelessSite =
    537              Services.eTLD.getSchemelessSiteFromHost(domainOrHost);
    538            clearData.deleteDataFromSite(
    539              schemelessSite,
    540              {},
    541              true,
    542              Ci.nsIClearDataService.CLEAR_COOKIES_AND_SITE_DATA |
    543                Ci.nsIClearDataService.CLEAR_ALL_CACHES,
    544              resolve
    545            );
    546          } else {
    547            clearData.deleteDataFromLocalFiles(
    548              true,
    549              Ci.nsIClearDataService.CLEAR_COOKIES_AND_SITE_DATA |
    550                Ci.nsIClearDataService.CLEAR_ALL_CACHES,
    551              resolve
    552            );
    553          }
    554        })
    555      );
    556    }
    557 
    558    await Promise.all(promises);
    559 
    560    return this.updateSites();
    561  },
    562 
    563  /**
    564   * In the specified window, shows a prompt for removing all site data or the
    565   * specified list of base domains or hosts, warning the user that this may log
    566   * them out of websites.
    567   *
    568   * @param {mozIDOMWindowProxy} win - a parent DOM window to host the dialog.
    569   * @param {string[]} [removals] - an array of base domain or host strings that
    570   * will be removed.
    571   * @returns {boolean} whether the user confirmed the prompt.
    572   */
    573  promptSiteDataRemoval(win, removals) {
    574    if (removals) {
    575      let args = {
    576        hosts: removals,
    577        allowed: false,
    578      };
    579      let features = "centerscreen,chrome,modal,resizable=no";
    580      win.browsingContext.topChromeWindow.openDialog(
    581        "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml",
    582        "",
    583        features,
    584        args
    585      );
    586      return args.allowed;
    587    }
    588 
    589    let brandName = lazy.gBrandBundle.GetStringFromName("brandShortName");
    590    let flags =
    591      Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
    592      Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
    593      Services.prompt.BUTTON_POS_0_DEFAULT;
    594    let title = lazy.gStringBundle.GetStringFromName(
    595      "clearSiteDataPromptTitle"
    596    );
    597    let text = lazy.gStringBundle.formatStringFromName(
    598      "clearSiteDataPromptText",
    599      [brandName]
    600    );
    601    let btn0Label = lazy.gStringBundle.GetStringFromName("clearSiteDataNow");
    602 
    603    let result = Services.prompt.confirmEx(
    604      win,
    605      title,
    606      text,
    607      flags,
    608      btn0Label,
    609      null,
    610      null,
    611      null,
    612      {}
    613    );
    614    return result == 0;
    615  },
    616 
    617  /**
    618   * Clears all site data and cache
    619   *
    620   * @returns a Promise that resolves when the data is cleared.
    621   */
    622  async removeAll() {
    623    await this.removeCache();
    624    return this.removeSiteData();
    625  },
    626 
    627  /**
    628   * Clears all caches.
    629   *
    630   * @returns a Promise that resolves when the data is cleared.
    631   */
    632  removeCache() {
    633    return new Promise(function (resolve) {
    634      Services.clearData.deleteData(
    635        Ci.nsIClearDataService.CLEAR_ALL_CACHES,
    636        resolve
    637      );
    638    });
    639  },
    640 
    641  /**
    642   * Clears all site data, but not cache, because the UI offers
    643   * that functionality separately.
    644   *
    645   * @returns a Promise that resolves when the data is cleared.
    646   */
    647  async removeSiteData() {
    648    await new Promise(function (resolve) {
    649      Services.clearData.deleteData(
    650        Ci.nsIClearDataService.CLEAR_COOKIES_AND_SITE_DATA,
    651        resolve
    652      );
    653    });
    654 
    655    return this.updateSites();
    656  },
    657 };