tor-browser

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

Utils.sys.mjs (18258B)


      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
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 import { ServiceRequest } from "resource://gre/modules/ServiceRequest.sys.mjs";
      7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      8 
      9 const lazy = {};
     10 
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
     13 });
     14 
     15 XPCOMUtils.defineLazyServiceGetter(
     16  lazy,
     17  "CaptivePortalService",
     18  "@mozilla.org/network/captive-portal-service;1",
     19  Ci.nsICaptivePortalService
     20 );
     21 XPCOMUtils.defineLazyServiceGetter(
     22  lazy,
     23  "gNetworkLinkService",
     24  "@mozilla.org/network/network-link-service;1",
     25  Ci.nsINetworkLinkService
     26 );
     27 
     28 // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
     29 // See LOG_LEVELS in Console.sys.mjs. Common examples: "all", "debug", "info",
     30 // "warn", "error".
     31 const log = (() => {
     32  const { ConsoleAPI } = ChromeUtils.importESModule(
     33    "resource://gre/modules/Console.sys.mjs"
     34  );
     35  return new ConsoleAPI({
     36    maxLogLevel: "warn",
     37    maxLogLevelPref: "services.settings.loglevel",
     38    prefix: "services.settings",
     39  });
     40 })();
     41 
     42 ChromeUtils.defineLazyGetter(lazy, "isRunningTests", () => {
     43  if (Services.env.get("MOZ_DISABLE_NONLOCAL_CONNECTIONS") === "1") {
     44    // Allow to override the server URL if non-local connections are disabled,
     45    // usually true when running tests.
     46    return true;
     47  }
     48  return false;
     49 });
     50 
     51 // Overriding the server URL is normally disabled on Beta and Release channels,
     52 // except under some conditions.
     53 ChromeUtils.defineLazyGetter(lazy, "allowServerURLOverride", () => {
     54  if (!AppConstants.RELEASE_OR_BETA) {
     55    // Always allow to override the server URL on Nightly/DevEdition.
     56    return true;
     57  }
     58 
     59  if (lazy.isRunningTests) {
     60    return true;
     61  }
     62 
     63  if (Services.env.get("MOZ_REMOTE_SETTINGS_DEVTOOLS") === "1") {
     64    // Allow to override the server URL when using remote settings devtools.
     65    return true;
     66  }
     67 
     68  if (lazy.gServerURL != AppConstants.REMOTE_SETTINGS_SERVER_URL) {
     69    log.warn("Ignoring preference override of remote settings server");
     70    log.warn(
     71      "Allow by setting MOZ_REMOTE_SETTINGS_DEVTOOLS=1 in the environment"
     72    );
     73  }
     74 
     75  return false;
     76 });
     77 
     78 XPCOMUtils.defineLazyPreferenceGetter(
     79  lazy,
     80  "gServerURL",
     81  "services.settings.server",
     82  AppConstants.REMOTE_SETTINGS_SERVER_URL
     83 );
     84 
     85 XPCOMUtils.defineLazyPreferenceGetter(
     86  lazy,
     87  "gPreviewEnabled",
     88  "services.settings.preview_enabled",
     89  false
     90 );
     91 
     92 function _isUndefined(value) {
     93  return typeof value === "undefined";
     94 }
     95 
     96 const _cdnURLs = {};
     97 
     98 export var Utils = {
     99  get SERVER_URL() {
    100    return lazy.allowServerURLOverride
    101      ? lazy.gServerURL
    102      : AppConstants.REMOTE_SETTINGS_SERVER_URL;
    103  },
    104 
    105  CHANGES_PATH: "/buckets/monitor/collections/changes/changeset",
    106 
    107  /**
    108   * Logger instance.
    109   */
    110  log,
    111 
    112  get shouldSkipRemoteActivityDueToTests() {
    113    return (
    114      (lazy.isRunningTests || Cu.isInAutomation) &&
    115      this.SERVER_URL == "data:,#remote-settings-dummy/v1"
    116    );
    117  },
    118 
    119  get CERT_CHAIN_ROOT_IDENTIFIER() {
    120    if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
    121      return Ci.nsIX509CertDB.AppXPCShellRoot;
    122    }
    123    if (
    124      this.SERVER_URL.match(
    125        /^https?:\/\/(remote-settings\.localhost|127\.0\.0\.1|localhost)(:\d+)?\/v1/
    126      )
    127    ) {
    128      return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot;
    129    }
    130    if (this.SERVER_URL.includes("allizom.")) {
    131      return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot;
    132    }
    133    if (this.SERVER_URL.includes("dev.")) {
    134      return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot;
    135    }
    136    return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
    137  },
    138 
    139  get LOAD_DUMPS() {
    140    // Load dumps only if pulling data from the production server, or in tests.
    141    return (
    142      this.SERVER_URL == AppConstants.REMOTE_SETTINGS_SERVER_URL ||
    143      lazy.isRunningTests
    144    );
    145  },
    146 
    147  get PREVIEW_MODE() {
    148    // We want to offer the ability to set preview mode via a preference
    149    // for consumers who want to pull from the preview bucket on startup.
    150    if (_isUndefined(this._previewModeEnabled) && lazy.allowServerURLOverride) {
    151      return lazy.gPreviewEnabled;
    152    }
    153    return !!this._previewModeEnabled;
    154  },
    155 
    156  /**
    157   * Internal method to enable pulling data from preview buckets.
    158   *
    159   * @param enabled
    160   */
    161  enablePreviewMode(enabled) {
    162    const bool2str = v =>
    163      // eslint-disable-next-line no-nested-ternary
    164      _isUndefined(v) ? "unset" : v ? "enabled" : "disabled";
    165    this.log.debug(
    166      `Preview mode: ${bool2str(this._previewModeEnabled)} -> ${bool2str(
    167        enabled
    168      )}`
    169    );
    170    this._previewModeEnabled = enabled;
    171  },
    172 
    173  /**
    174   * Returns the actual bucket name to be used. When preview mode is enabled,
    175   * this adds the *preview* suffix.
    176   *
    177   * See also `SharedUtils.loadJSONDump()` which strips the preview suffix to identify
    178   * the packaged JSON file.
    179   *
    180   * @param bucketName the client bucket
    181   * @returns the final client bucket depending whether preview mode is enabled.
    182   */
    183  actualBucketName(bucketName) {
    184    let actual = bucketName.replace("-preview", "");
    185    if (this.PREVIEW_MODE) {
    186      actual += "-preview";
    187    }
    188    return actual;
    189  },
    190 
    191  /**
    192   * Check if network is down.
    193   *
    194   * Note that if this returns false, it does not guarantee
    195   * that network is up.
    196   *
    197   * @return {bool} Whether network is down or not.
    198   */
    199  get isOffline() {
    200    try {
    201      return (
    202        Services.io.offline ||
    203        lazy.CaptivePortalService.state ==
    204          lazy.CaptivePortalService.LOCKED_PORTAL ||
    205        !lazy.gNetworkLinkService.isLinkUp
    206      );
    207    } catch (ex) {
    208      log.warn("Could not determine network status.", ex);
    209    }
    210    return false;
    211  },
    212 
    213  /**
    214   * A wrapper around `ServiceRequest` that behaves like `fetch()`.
    215   *
    216   * Use this in order to leverage the `beConservative` flag, for
    217   * example to avoid using HTTP3 to fetch critical data.
    218   *
    219   * @param input a resource
    220   * @param init request options
    221   * @returns a Response object
    222   */
    223  async fetch(input, init = {}) {
    224    return new Promise(function (resolve, reject) {
    225      const request = new ServiceRequest();
    226      function fallbackOrReject(err) {
    227        if (
    228          // At most one recursive Utils.fetch call (bypassProxy=false to true).
    229          bypassProxy ||
    230          Services.startup.shuttingDown ||
    231          Utils.isOffline ||
    232          !request.isProxied ||
    233          !request.bypassProxyEnabled
    234        ) {
    235          reject(err);
    236          return;
    237        }
    238        ServiceRequest.logProxySource(request.channel, "remote-settings");
    239        resolve(Utils.fetch(input, { ...init, bypassProxy: true }));
    240      }
    241 
    242      request.onerror = () =>
    243        fallbackOrReject(new TypeError("NetworkError: Network request failed"));
    244      request.ontimeout = () =>
    245        fallbackOrReject(new TypeError("Timeout: Network request failed"));
    246      request.onabort = () =>
    247        fallbackOrReject(new DOMException("Aborted", "AbortError"));
    248      request.onload = () => {
    249        // Parse raw response headers into `Headers` object.
    250        const headers = new Headers();
    251        const rawHeaders = request.getAllResponseHeaders();
    252        rawHeaders
    253          .trim()
    254          .split(/[\r\n]+/)
    255          .forEach(line => {
    256            const parts = line.split(": ");
    257            const header = parts.shift();
    258            const value = parts.join(": ");
    259            headers.set(header, value);
    260          });
    261 
    262        const responseAttributes = {
    263          status: request.status,
    264          statusText: request.statusText,
    265          url: request.responseURL,
    266          headers,
    267        };
    268        resolve(new Response(request.response, responseAttributes));
    269      };
    270 
    271      const { method = "GET", headers = {}, bypassProxy = false } = init;
    272 
    273      request.open(method, input, { bypassProxy });
    274      // By default, XMLHttpRequest converts the response based on the
    275      // Content-Type header, or UTF-8 otherwise. This may mangle binary
    276      // responses. Avoid that by requesting the raw bytes.
    277      request.responseType = "arraybuffer";
    278 
    279      for (const [name, value] of Object.entries(headers)) {
    280        request.setRequestHeader(name, value);
    281      }
    282 
    283      request.send();
    284    });
    285  },
    286 
    287  /**
    288   * Retrieves the base URL for attachments from the server configuration.
    289   *
    290   * If the URL has been previously fetched and cached, it returns the cached URL.
    291   *
    292   * @async
    293   * @function baseAttachmentsURL
    294   * @memberof Utils
    295   * @returns {Promise<string>} A promise that resolves to the base URL for attachments.
    296   *
    297   * @throws {Error} If there is an error fetching or parsing the server response.
    298   *
    299   * @example
    300   * const attachmentsURL = await Downloader.baseAttachmentsURL();
    301   * console.log(attachmentsURL);
    302   */
    303  async baseAttachmentsURL() {
    304    if (!_cdnURLs[Utils.SERVER_URL]) {
    305      const resp = await Utils.fetch(`${Utils.SERVER_URL}/`);
    306      const serverInfo = await resp.json();
    307      // Server capabilities expose attachments configuration.
    308      const {
    309        capabilities: {
    310          attachments: { base_url },
    311        },
    312      } = serverInfo;
    313      // Make sure the URL always has a trailing slash.
    314      _cdnURLs[Utils.SERVER_URL] =
    315        base_url + (base_url.endsWith("/") ? "" : "/");
    316    }
    317    return _cdnURLs[Utils.SERVER_URL];
    318  },
    319 
    320  /**
    321   * Check if local data exist for the specified client.
    322   *
    323   * @param {RemoteSettingsClient} client
    324   * @return {bool} Whether it exists or not.
    325   */
    326  async hasLocalData(client) {
    327    const timestamp = await client.db.getLastModified();
    328    return timestamp !== null;
    329  },
    330 
    331  /**
    332   * Check if we ship a JSON dump for the specified bucket and collection.
    333   *
    334   * @param {string} bucket
    335   * @param {string} collection
    336   * @return {bool} Whether it is present or not.
    337   */
    338  async hasLocalDump(bucket, collection) {
    339    try {
    340      await fetch(
    341        `resource://app/defaults/settings/${bucket}/${collection}.json`,
    342        {
    343          method: "HEAD",
    344        }
    345      );
    346      return true;
    347    } catch (e) {
    348      return false;
    349    }
    350  },
    351 
    352  /**
    353   * Look up the last modification time of the JSON dump.
    354   *
    355   * @param {string} bucket
    356   * @param {string} collection
    357   * @return {int} The last modification time of the dump. -1 if non-existent.
    358   */
    359  async getLocalDumpLastModified(bucket, collection) {
    360    if (!this._dumpStats) {
    361      if (!this._dumpStatsInitPromise) {
    362        this._dumpStatsInitPromise = (async () => {
    363          try {
    364            let res = await fetch(
    365              "resource://app/defaults/settings/last_modified.json"
    366            );
    367            this._dumpStats = await res.json();
    368          } catch (e) {
    369            log.warn(`Failed to load last_modified.json: ${e}`);
    370            this._dumpStats = {};
    371          }
    372          delete this._dumpStatsInitPromise;
    373        })();
    374      }
    375      await this._dumpStatsInitPromise;
    376    }
    377    const identifier = `${bucket}/${collection}`;
    378    let lastModified = this._dumpStats[identifier];
    379    if (lastModified === undefined) {
    380      const { timestamp: dumpTimestamp } = await lazy.SharedUtils.loadJSONDump(
    381        bucket,
    382        collection
    383      );
    384      // Client recognize -1 as missing dump.
    385      lastModified = dumpTimestamp ?? -1;
    386      this._dumpStats[identifier] = lastModified;
    387    }
    388    return lastModified;
    389  },
    390 
    391  /**
    392   * Fetch the list of remote collections and their timestamp.
    393   * ```
    394   *   {
    395   *     "timestamp": 1486545678,
    396   *     "changes":[
    397   *       {
    398   *         "host":"kinto-ota.dev.mozaws.net",
    399   *         "last_modified":1450717104423,
    400   *         "bucket":"blocklists",
    401   *         "collection":"certificates"
    402   *       },
    403   *       ...
    404   *     ],
    405   *     "metadata": {}
    406   *   }
    407   * ```
    408   *
    409   * @param {string} serverUrl         The server URL (eg. `https://server.org/v1`)
    410   * @param {int}    expectedTimestamp The timestamp that the server is supposed to return.
    411   *                                   We obtained it from the Megaphone notification payload,
    412   *                                   and we use it only for cache busting (Bug 1497159).
    413   * @param {string} lastEtag          (optional) The Etag of the latest poll to be matched
    414   *                                   by the server (eg. `"123456789"`).
    415   * @param {object} filters
    416   */
    417  async fetchLatestChanges(serverUrl, options = {}) {
    418    const { expectedTimestamp, lastEtag = "", filters = {} } = options;
    419 
    420    let url = serverUrl + Utils.CHANGES_PATH;
    421    const params = {
    422      ...filters,
    423      _expected: expectedTimestamp ?? 0,
    424    };
    425    if (lastEtag != "") {
    426      params._since = lastEtag;
    427    }
    428    if (params) {
    429      url +=
    430        "?" +
    431        Object.entries(params)
    432          .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
    433          .join("&");
    434    }
    435    const response = await Utils.fetch(url);
    436 
    437    if (response.status >= 500) {
    438      throw new Error(`Server error ${response.status} ${response.statusText}`);
    439    }
    440 
    441    const is404FromCustomServer =
    442      response.status == 404 &&
    443      Services.prefs.prefHasUserValue("services.settings.server");
    444 
    445    const ct = response.headers.get("Content-Type");
    446    if (!is404FromCustomServer && (!ct || !ct.includes("application/json"))) {
    447      throw new Error(`Unexpected content-type "${ct}"`);
    448    }
    449 
    450    let payload;
    451    try {
    452      payload = await response.json();
    453    } catch (e) {
    454      payload = e.message;
    455    }
    456 
    457    if (!payload.hasOwnProperty("changes")) {
    458      // If the server is failing, the JSON response might not contain the
    459      // expected data. For example, real server errors (Bug 1259145)
    460      // or dummy local server for tests (Bug 1481348)
    461      if (!is404FromCustomServer) {
    462        throw new Error(
    463          `Server error ${url} ${response.status} ${
    464            response.statusText
    465          }: ${JSON.stringify(payload)}`
    466        );
    467      }
    468    }
    469 
    470    const { changes = [], timestamp } = payload;
    471 
    472    let serverTimeMillis = Date.parse(response.headers.get("Date"));
    473    // Since the response is served via a CDN, the Date header value could have been cached.
    474    const cacheAgeSeconds = response.headers.has("Age")
    475      ? parseInt(response.headers.get("Age"), 10)
    476      : 0;
    477    serverTimeMillis += cacheAgeSeconds * 1000;
    478 
    479    // Age of data (time between publication and now).
    480    const ageSeconds = (serverTimeMillis - timestamp) / 1000;
    481 
    482    // Check if the server asked the clients to back off.
    483    let backoffSeconds;
    484    if (response.headers.has("Backoff")) {
    485      const value = parseInt(response.headers.get("Backoff"), 10);
    486      if (!isNaN(value)) {
    487        backoffSeconds = value;
    488      }
    489    }
    490 
    491    return {
    492      changes,
    493      currentEtag: `"${timestamp}"`,
    494      serverTimeMillis,
    495      backoffSeconds,
    496      ageSeconds,
    497    };
    498  },
    499 
    500  /**
    501   * Test if a single object matches all given filters.
    502   *
    503   * @param  {object} filters  The filters object.
    504   * @param  {object} entry    The object to filter.
    505   * @return {boolean}
    506   */
    507  filterObject(filters, entry) {
    508    return Object.entries(filters).every(([filter, value]) => {
    509      if (Array.isArray(value)) {
    510        return value.some(candidate => candidate === entry[filter]);
    511      } else if (typeof value === "object") {
    512        return Utils.filterObject(value, entry[filter]);
    513      } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
    514        log.debug(`The property ${filter} does not exist`);
    515        return false;
    516      }
    517      return entry[filter] === value;
    518    });
    519  },
    520 
    521  /**
    522   * Sorts records in a list according to a given ordering.
    523   *
    524   * @param  {string} order The ordering, eg. `-last_modified`.
    525   * @param  {Array}  list  The collection to order.
    526   * @return {Array}
    527   */
    528  sortObjects(order, list) {
    529    const hasDash = order[0] === "-";
    530    const field = hasDash ? order.slice(1) : order;
    531    const direction = hasDash ? -1 : 1;
    532    return list.slice().sort((a, b) => {
    533      if (a[field] && _isUndefined(b[field])) {
    534        return direction;
    535      }
    536      if (b[field] && _isUndefined(a[field])) {
    537        return -direction;
    538      }
    539      if (_isUndefined(a[field]) && _isUndefined(b[field])) {
    540        return 0;
    541      }
    542      return a[field] > b[field] ? direction : -direction;
    543    });
    544  },
    545 
    546  /**
    547   * Fetches and extracts a bundle of changesets from the server.
    548   *
    549   * This function downloads a JSON file with all changesets required during startup, compressed as LZ4.
    550   * It writes the LZ4 file to a temporary location, extracts it, and return the array of changesets.
    551   * We chose to use LZ4 instead of Zip because extraction can happen off the main thread.
    552   *
    553   * @async
    554   * @function fetchChangesetsBundle
    555   * @memberof Utils
    556   * @returns {Promise<Array<object>>} A promise that resolves to an array of parsed changesets.
    557   *
    558   * @throws {Error} Throws an error if there is an issue fetching the server info or the changeset bundle,
    559   *                 or if there is an error during the extraction and parsing of the changesets.
    560   */
    561  async fetchChangesetsBundle() {
    562    const tmpLz4File = await IOUtils.createUniqueFile(
    563      PathUtils.tempDir,
    564      "remote-settings-startup-bundle-"
    565    );
    566    try {
    567      const baseUrl = await Utils.baseAttachmentsURL();
    568      const bundleUrl = `${baseUrl}bundles/startup.json.mozlz4`;
    569      const bundleResp = await Utils.fetch(bundleUrl);
    570      if (!bundleResp.ok) {
    571        throw new Error(`Cannot fetch changeset bundle from ${bundleUrl}`);
    572      }
    573      // Write down the LZ4 in a temporary file.
    574      const downloaded = await bundleResp.arrayBuffer();
    575      await IOUtils.write(tmpLz4File, new Uint8Array(downloaded), {
    576        tmpPath: `${tmpLz4File}.tmp`,
    577      });
    578      // Decompress using LZ4
    579      const changesetsJson = await IOUtils.readUTF8(tmpLz4File, {
    580        decompress: true,
    581      });
    582      // Parse JSON from string
    583      return JSON.parse(changesetsJson);
    584    } finally {
    585      await IOUtils.remove(tmpLz4File, { ignoreAbsent: true });
    586    }
    587  },
    588 };