tor-browser

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

interventions.js (18605B)


      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 "use strict";
      6 
      7 /* globals browser, InterventionHelpers */
      8 
      9 const debugLoggingPrefValue = browser.aboutConfigPrefs.getPref(
     10  "disable_debug_logging"
     11 );
     12 let debugLog = function () {
     13  if (debugLoggingPrefValue !== true) {
     14    console.debug.apply(this, arguments);
     15  }
     16 };
     17 
     18 class Interventions {
     19  constructor(availableInterventions, customFunctions) {
     20    this._originalInterventions = availableInterventions;
     21 
     22    this.INTERVENTION_PREF = "enable_interventions";
     23 
     24    this._interventionsEnabled = true;
     25 
     26    this._readyPromise = new Promise(done => (this._resolveReady = done));
     27 
     28    this._disabledPrefListeners = {};
     29 
     30    this._availableInterventions = this._reformatSourceJSON(
     31      availableInterventions
     32    );
     33    this._customFunctions = customFunctions;
     34 
     35    this._activeListenersPerIntervention = new Map();
     36    this._contentScriptsPerIntervention = new Map();
     37  }
     38 
     39  _reformatSourceJSON(availableInterventions) {
     40    return Object.entries(availableInterventions).map(([id, obj]) => {
     41      obj.id = id;
     42      return obj;
     43    });
     44  }
     45 
     46  async onRemoteSettingsUpdate(updatedInterventions) {
     47    const oldReadyPromise = this._readyPromise;
     48    this._readyPromise = new Promise(done => (this._resolveReady = done));
     49    await oldReadyPromise;
     50    this._updateInterventions(updatedInterventions);
     51  }
     52 
     53  async _updateInterventions(updatedInterventions) {
     54    await this.disableInterventions();
     55    this._availableInterventions =
     56      this._reformatSourceJSON(updatedInterventions);
     57    await this.enableInterventions();
     58  }
     59 
     60  async _resetToDefaultInterventions() {
     61    await this._updateInterventions(this._originalInterventions);
     62  }
     63 
     64  ready() {
     65    return this._readyPromise;
     66  }
     67 
     68  bindAboutCompatBroker(broker) {
     69    this._aboutCompatBroker = broker;
     70  }
     71 
     72  bootup() {
     73    browser.aboutConfigPrefs.onPrefChange.addListener(() => {
     74      this.checkInterventionPref();
     75    }, this.INTERVENTION_PREF);
     76    this.checkInterventionPref();
     77  }
     78 
     79  async updateInterventions(_data) {
     80    const data = structuredClone(_data);
     81    await this.disableInterventions(data);
     82    await this.enableInterventions(data);
     83    for (const intervention of data) {
     84      const { id } = intervention;
     85      const i = this._availableInterventions.findIndex(v => v.id === id);
     86      if (i > -1) {
     87        this._availableInterventions[i] = intervention;
     88      } else {
     89        this._availableInterventions.push(intervention);
     90      }
     91    }
     92    return data;
     93  }
     94 
     95  checkInterventionPref() {
     96    navigator.locks.request("pref_check_lock", async () => {
     97      const value = browser.aboutConfigPrefs.getPref(this.INTERVENTION_PREF);
     98      if (value === undefined) {
     99        await browser.aboutConfigPrefs.setPref(this.INTERVENTION_PREF, true);
    100      } else if (value === false) {
    101        await this.disableInterventions();
    102      } else {
    103        await this.enableInterventions();
    104      }
    105    });
    106  }
    107 
    108  getAvailableInterventions() {
    109    return this._availableInterventions;
    110  }
    111 
    112  _getActiveInterventionById(whichId) {
    113    return this._availableInterventions.find(({ id }) => id === whichId);
    114  }
    115 
    116  isEnabled() {
    117    return this._interventionsEnabled;
    118  }
    119 
    120  async enableInterventions(whichInterventions = this._availableInterventions) {
    121    return navigator.locks.request("intervention_lock", async () => {
    122      await this._enableInterventionsNow(whichInterventions);
    123    });
    124  }
    125 
    126  async disableInterventions(
    127    whichInterventions = this._availableInterventions
    128  ) {
    129    return navigator.locks.request("intervention_lock", async () => {
    130      for (const config of whichInterventions) {
    131        const disabling_pref_listener = this._disabledPrefListeners[config.id];
    132        if (disabling_pref_listener) {
    133          browser.aboutConfigPrefs.onPrefChange.removeListener(
    134            disabling_pref_listener
    135          );
    136          delete this._disabledPrefListeners[config.id];
    137        }
    138 
    139        await this._disableInterventionNow(config);
    140      }
    141 
    142      this._interventionsEnabled = false;
    143      this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
    144        interventionsChanged: false,
    145      });
    146    });
    147  }
    148 
    149  #checkedPrefListeners = new Map();
    150  #checkedPrefCache = new Map();
    151 
    152  async onCheckedPrefChanged(pref) {
    153    navigator.locks.request("pref_check_lock", async () => {
    154      this.#checkedPrefCache.delete(pref);
    155      const toRecheck = this._availableInterventions.filter(cfg =>
    156        cfg.interventions.find(i => i.pref_check && pref in i.pref_check)
    157      );
    158      await this.updateInterventions(toRecheck);
    159    });
    160  }
    161 
    162  _check_for_needed_prefs(intervention) {
    163    if (!intervention.pref_check) {
    164      return true;
    165    }
    166    for (const pref of Object.keys(intervention.pref_check ?? {})) {
    167      if (!this.#checkedPrefListeners.has(pref)) {
    168        const listener = () => this.onCheckedPrefChanged(pref);
    169        this.#checkedPrefListeners.set(pref, listener);
    170        browser.aboutConfigPrefs.onPrefChange.addListener(listener, pref);
    171      }
    172    }
    173    for (const [pref, value] of Object.entries(intervention.pref_check ?? {})) {
    174      if (!this.#checkedPrefCache.has(pref)) {
    175        this.#checkedPrefCache.set(
    176          pref,
    177          browser.aboutConfigPrefs.getPref(pref)
    178        );
    179      }
    180      if (value !== this.#checkedPrefCache.get(pref)) {
    181        return false;
    182      }
    183    }
    184    return true;
    185  }
    186 
    187  async _enableInterventionsNow(whichInterventions) {
    188    // resolveReady may change while we're updating
    189    const resolveReady = this._resolveReady;
    190 
    191    const skipped = [];
    192 
    193    const channel = await browser.appConstants.getEffectiveUpdateChannel();
    194    const version =
    195      this.versionForTesting ??
    196      (await browser.runtime.getBrowserInfo()).version;
    197    const cleanVersion = parseFloat(version.match(/\d+(\.\d+)?/)[0]);
    198 
    199    const os = await InterventionHelpers.getOS();
    200    this.currentPlatform = os;
    201 
    202    const customFunctionNames = new Set(Object.keys(this._customFunctions));
    203 
    204    const contentScriptsToRegister = [];
    205    for (const config of whichInterventions) {
    206      config.active = false;
    207 
    208      if (config.isMissingFiles) {
    209        skipped.push(config.label);
    210        continue;
    211      }
    212 
    213      config.DISABLING_PREF = `disabled_interventions.${config.id}`;
    214      const disabledPrefListener = () => {
    215        navigator.locks.request("pref_check_lock", async () => {
    216          const value = browser.aboutConfigPrefs.getPref(config.DISABLING_PREF);
    217          if (value === true) {
    218            await this.disableIntervention(config);
    219            debugLog(
    220              `Webcompat intervention for ${config.label} disabled by pref`
    221            );
    222          } else {
    223            await this.enableIntervention(config);
    224            debugLog(
    225              `Webcompat intervention for ${config.label} enabled by pref`
    226            );
    227          }
    228          this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
    229            interventionsChanged:
    230              this._aboutCompatBroker.filterInterventions(whichInterventions),
    231          });
    232        });
    233      };
    234      this._disabledPrefListeners[config.id] = disabledPrefListener;
    235      browser.aboutConfigPrefs.onPrefChange.addListener(
    236        disabledPrefListener,
    237        config.DISABLING_PREF
    238      );
    239 
    240      const disablingPrefValue = browser.aboutConfigPrefs.getPref(
    241        config.DISABLING_PREF
    242      );
    243 
    244      for (const intervention of config.interventions) {
    245        intervention.enabled = false;
    246        if (!this._check_for_needed_prefs(intervention)) {
    247          continue;
    248        }
    249        if (
    250          InterventionHelpers.shouldSkip(intervention, cleanVersion, channel)
    251        ) {
    252          continue;
    253        }
    254        if (
    255          InterventionHelpers.isMissingCustomFunctions(
    256            intervention,
    257            customFunctionNames
    258          )
    259        ) {
    260          continue;
    261        }
    262        if (!(await InterventionHelpers.checkPlatformMatches(intervention))) {
    263          // special case: allow platforms=[] to indicate "disabled by default"
    264          if (
    265            intervention.platforms &&
    266            !intervention.platforms.length &&
    267            !intervention.not_platforms
    268          ) {
    269            config.availableOnPlatform = true;
    270          }
    271          continue;
    272        }
    273        intervention.enabled = true;
    274        config.availableOnPlatform = true;
    275      }
    276 
    277      if (!config.availableOnPlatform) {
    278        skipped.push(config.label);
    279        continue;
    280      }
    281      if (disablingPrefValue === true) {
    282        skipped.push(config.label);
    283        continue;
    284      }
    285 
    286      try {
    287        contentScriptsToRegister.push(
    288          ...(await this._enableInterventionNow(config))
    289        );
    290      } catch (e) {
    291        console.error("Error enabling intervention(s) for", config.label, e);
    292      }
    293    }
    294    this._registerContentScripts(contentScriptsToRegister);
    295 
    296    if (skipped.length) {
    297      debugLog(
    298        "Skipping",
    299        skipped.length,
    300        "un-needed interventions",
    301        skipped.sort()
    302      );
    303    }
    304 
    305    this._interventionsEnabled = true;
    306    this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
    307      interventionsChanged:
    308        this._aboutCompatBroker.filterInterventions(whichInterventions),
    309    });
    310 
    311    resolveReady();
    312  }
    313 
    314  async enableIntervention(config, force = false) {
    315    return navigator.locks.request("intervention_lock", async () => {
    316      await this._enableInterventionNow(config, {
    317        force,
    318        registerContentScripts: true,
    319      });
    320    });
    321  }
    322 
    323  async disableIntervention(config) {
    324    return navigator.locks.request("intervention_lock", async () => {
    325      await this._disableInterventionNow(config);
    326    });
    327  }
    328 
    329  async _enableInterventionNow(config, options = {}) {
    330    const { force = false, registerContentScripts } = options;
    331    if (config.active) {
    332      return [];
    333    }
    334 
    335    const { bugs, label } = config;
    336    const blocks = Object.values(bugs)
    337      .map(bug => bug.blocks)
    338      .flat()
    339      .filter(v => v !== undefined);
    340    const matches = Object.values(bugs)
    341      .map(bug => bug.matches)
    342      .flat()
    343      .filter(v => v !== undefined);
    344 
    345    let somethingWasEnabled = false;
    346    let contentScriptsToRegister = [];
    347    for (const intervention of config.interventions) {
    348      if (!intervention.enabled && !force) {
    349        continue;
    350      }
    351 
    352      await this._changeCustomFuncs("enable", label, intervention, config);
    353      if (intervention.content_scripts) {
    354        const contentScriptsForIntervention =
    355          this._buildContentScriptRegistrations(label, intervention, matches);
    356        this._contentScriptsPerIntervention.set(
    357          intervention,
    358          contentScriptsForIntervention
    359        );
    360        contentScriptsToRegister.push(...contentScriptsForIntervention);
    361      }
    362      await this._enableUAOverrides(label, intervention, matches);
    363      await this._enableRequestBlocks(label, intervention, blocks);
    364      somethingWasEnabled = true;
    365      intervention.enabled = true;
    366    }
    367    if (registerContentScripts) {
    368      this._registerContentScripts(contentScriptsToRegister);
    369    }
    370 
    371    if (!this._getActiveInterventionById(config.id)) {
    372      this._availableInterventions.push(config);
    373      debugLog("Added webcompat intervention", config.id, config);
    374    } else {
    375      for (const [index, oldConfig] of this._availableInterventions.entries()) {
    376        if (oldConfig.id === config.id && oldConfig !== config) {
    377          debugLog("Replaced webcompat intervention", oldConfig.id, config);
    378          this._availableInterventions[index] = config;
    379        }
    380      }
    381    }
    382 
    383    config.active = somethingWasEnabled;
    384    return contentScriptsToRegister;
    385  }
    386 
    387  async _disableInterventionNow(_config) {
    388    const config = this._getActiveInterventionById(_config?.id ?? _config);
    389    if (!config) {
    390      return;
    391    }
    392 
    393    const { active, label, interventions } = config;
    394 
    395    if (!active) {
    396      return;
    397    }
    398 
    399    for (const intervention of interventions) {
    400      if (!intervention.enabled) {
    401        continue;
    402      }
    403 
    404      await this._changeCustomFuncs("disable", label, intervention, config);
    405      if (intervention.content_scripts) {
    406        await this._disableContentScripts(label, intervention);
    407      }
    408 
    409      // This covers both request blocks and ua_string cases
    410      const listeners = this._activeListenersPerIntervention.get(intervention);
    411      if (listeners) {
    412        for (const [name, listener] of Object.entries(listeners)) {
    413          browser.webRequest[name].removeListener(listener);
    414        }
    415        this._activeListenersPerIntervention.delete(intervention);
    416      }
    417    }
    418 
    419    config.active = false;
    420  }
    421 
    422  async _changeCustomFuncs(action, label, intervention, config) {
    423    for (const [customFuncName, customFunc] of Object.entries(
    424      this._customFunctions
    425    )) {
    426      if (customFuncName in intervention) {
    427        for (const details of intervention[customFuncName]) {
    428          try {
    429            await customFunc[action](details, config);
    430          } catch (e) {
    431            console.trace(
    432              `Error while calling custom function ${customFuncName}.${action} for ${label}:`,
    433              e
    434            );
    435          }
    436        }
    437      }
    438    }
    439  }
    440 
    441  async _enableUAOverrides(label, intervention, matches) {
    442    if (!("ua_string" in intervention)) {
    443      return;
    444    }
    445 
    446    let listeners = this._activeListenersPerIntervention.get(intervention);
    447    if (!listeners) {
    448      listeners = {};
    449      this._activeListenersPerIntervention.set(intervention, listeners);
    450    }
    451 
    452    const listener = details => {
    453      const { enabled, ua_string } = intervention;
    454 
    455      // Don't actually override the UA for an experiment if the user is not
    456      // part of the experiment (unless they force-enabed the override).
    457      if (
    458        enabled &&
    459        (!intervention.experiment || intervention.permanentPrefEnabled === true)
    460      ) {
    461        for (const header of details.requestHeaders) {
    462          if (header.name.toLowerCase() !== "user-agent") {
    463            continue;
    464          }
    465 
    466          // Don't override the UA if we're on a mobile device that has the
    467          // "Request Desktop Site" mode enabled. The UA for the desktop mode
    468          // is set inside Gecko with a simple string replace, so we can use
    469          // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28
    470          let isMobileWithDesktopMode =
    471            this.currentPlatform == "android" &&
    472            header.value.includes("X11; Linux x86_64");
    473          if (isMobileWithDesktopMode) {
    474            continue;
    475          }
    476 
    477          header.value = InterventionHelpers.applyUAChanges(
    478            header.value,
    479            ua_string
    480          );
    481        }
    482      }
    483      return { requestHeaders: details.requestHeaders };
    484    };
    485 
    486    browser.webRequest.onBeforeSendHeaders.addListener(
    487      listener,
    488      { urls: matches },
    489      ["blocking", "requestHeaders"]
    490    );
    491 
    492    listeners.onBeforeSendHeaders = listener;
    493 
    494    debugLog(`Enabled UA override for ${label}`);
    495  }
    496 
    497  async _enableRequestBlocks(label, intervention, blocks) {
    498    if (!blocks.length) {
    499      return;
    500    }
    501 
    502    let listeners = this._activeListenersPerIntervention.get(intervention);
    503    if (!listeners) {
    504      listeners = {};
    505      this._activeListenersPerIntervention.set(intervention, listeners);
    506    }
    507 
    508    const listener = () => {
    509      return { cancel: true };
    510    };
    511 
    512    browser.webRequest.onBeforeRequest.addListener(listener, { urls: blocks }, [
    513      "blocking",
    514    ]);
    515 
    516    listeners.onBeforeRequest = listener;
    517    debugLog(`Blocking requests as specified for ${label}`);
    518  }
    519 
    520  async _registerContentScripts(scriptsToReg) {
    521    // Try to avoid re-registering scripts already registered
    522    // (e.g. if the webcompat background page is restarted
    523    // after an extension process crash, after having registered
    524    // the content scripts already once), but do not prevent
    525    // to try registering them again if the getRegisteredContentScripts
    526    // method returns an unexpected rejection.
    527 
    528    const ids = scriptsToReg.map(s => s.id);
    529    if (!ids.length) {
    530      return;
    531    }
    532    try {
    533      const alreadyRegged = await browser.scripting.getRegisteredContentScripts(
    534        { ids }
    535      );
    536      const alreadyReggedIds = alreadyRegged.map(script => script.id);
    537      const stillNeeded = scriptsToReg.filter(
    538        ({ id }) => !alreadyReggedIds.includes(id)
    539      );
    540      await browser.scripting.registerContentScripts(stillNeeded);
    541      debugLog(
    542        `Registered still-not-active webcompat content scripts`,
    543        stillNeeded
    544      );
    545    } catch (e) {
    546      try {
    547        await browser.scripting.registerContentScripts(scriptsToReg);
    548        debugLog(
    549          `Registered all webcompat content scripts after error registering just non-active ones`,
    550          scriptsToReg,
    551          e
    552        );
    553      } catch (e2) {
    554        console.error(
    555          `Error while registering webcompat content scripts:`,
    556          e2,
    557          scriptsToReg
    558        );
    559      }
    560    }
    561  }
    562 
    563  async _disableContentScripts(label, intervention) {
    564    const contentScripts =
    565      this._contentScriptsPerIntervention.get(intervention);
    566    if (contentScripts) {
    567      for (const id of contentScripts.map(s => s.id)) {
    568        try {
    569          await browser.scripting.unregisterContentScripts({ ids: [id] });
    570        } catch (_) {}
    571      }
    572    }
    573  }
    574 
    575  _buildContentScriptRegistrations(label, intervention, matches) {
    576    const registration = {
    577      id: `webcompat intervention for ${label}: ${JSON.stringify(intervention.content_scripts)}`,
    578      matches,
    579      persistAcrossSessions: false,
    580    };
    581 
    582    let { all_frames, css, js, run_at } = intervention.content_scripts;
    583    if (!css && !js) {
    584      console.error(`Missing js or css for content_script in ${label}`);
    585      return [];
    586    }
    587    if (all_frames) {
    588      registration.allFrames = true;
    589    }
    590    if (css) {
    591      registration.css = css.map(item => {
    592        if (item.includes("/")) {
    593          return item;
    594        }
    595        return `injections/css/${item}`;
    596      });
    597    }
    598    if (js) {
    599      registration.js = js.map(item => {
    600        if (item.includes("/")) {
    601          return item;
    602        }
    603        return `injections/js/${item}`;
    604      });
    605    }
    606    if (run_at) {
    607      registration.runAt = run_at;
    608    } else {
    609      registration.runAt = "document_start";
    610    }
    611 
    612    return [registration];
    613  }
    614 }