tor-browser

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

UrlClassifierExceptionListService.sys.mjs (11037B)


      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 export function UrlClassifierExceptionListService() {}
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
     11 });
     12 
     13 const COLLECTION_NAME = "url-classifier-exceptions";
     14 
     15 class Feature {
     16  constructor(name, prefName) {
     17    this.name = name;
     18    this.prefName = prefName;
     19    this.observers = new Set();
     20    this.prefValue = null;
     21    this.remoteEntries = null;
     22 
     23    if (prefName) {
     24      this.prefValue = Services.prefs.getStringPref(this.prefName, null);
     25      Services.prefs.addObserver(prefName, this);
     26    }
     27  }
     28 
     29  async addAndRunObserver(observer) {
     30    this.observers.add(observer);
     31    this.notifyObservers(observer);
     32  }
     33 
     34  removeObserver(observer) {
     35    this.observers.delete(observer);
     36  }
     37 
     38  observe(subject, topic, data) {
     39    if (topic != "nsPref:changed" || data != this.prefName) {
     40      console.error(`Unexpected event ${topic} with ${data}`);
     41      return;
     42    }
     43 
     44    this.prefValue = Services.prefs.getStringPref(this.prefName, null);
     45    this.notifyObservers();
     46  }
     47 
     48  onRemoteSettingsUpdate(entries) {
     49    this.remoteEntries = [];
     50 
     51    for (let jsEntry of entries) {
     52      let { classifierFeatures } = jsEntry;
     53      if (classifierFeatures.includes(this.name)) {
     54        let entry = Feature.rsObjectToEntry(jsEntry);
     55        if (entry) {
     56          this.remoteEntries.push(entry);
     57        }
     58      }
     59    }
     60  }
     61 
     62  /**
     63   * Convert a JS object from RemoteSettings to an nsIUrlClassifierExceptionListEntry.
     64   *
     65   * @param {object} rsObject - The JS object from RemoteSettings to convert.
     66   * @returns {nsIUrlClassifierExceptionListEntry} The converted nsIUrlClassifierExceptionListEntry.
     67   */
     68  static rsObjectToEntry(rsObject) {
     69    let entry = Cc[
     70      "@mozilla.org/url-classifier/exception-list-entry;1"
     71    ].createInstance(Ci.nsIUrlClassifierExceptionListEntry);
     72 
     73    let {
     74      category: categoryStr,
     75      urlPattern,
     76      topLevelUrlPattern = "",
     77      isPrivateBrowsingOnly = false,
     78      filterContentBlockingCategories = [],
     79      classifierFeatures = [],
     80    } = rsObject;
     81 
     82    const CATEGORY_STR_TO_ENUM = {
     83      "internal-pref":
     84        Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_INTERNAL_PREF,
     85      baseline: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_BASELINE,
     86      convenience: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_CONVENIENCE,
     87    };
     88 
     89    let category = CATEGORY_STR_TO_ENUM[categoryStr];
     90    if (category == null) {
     91      console.error(
     92        "Invalid or unknown category",
     93        { rsObject },
     94        { categories: Object.keys(CATEGORY_STR_TO_ENUM) }
     95      );
     96      return null;
     97    }
     98 
     99    try {
    100      entry.init(
    101        category,
    102        urlPattern,
    103        topLevelUrlPattern,
    104        isPrivateBrowsingOnly,
    105        filterContentBlockingCategories,
    106        classifierFeatures
    107      );
    108    } catch (e) {
    109      console.error(
    110        "Error initializing url classifier exception list entry " + e.message,
    111        e,
    112        { rsObject }
    113      );
    114      return null;
    115    }
    116 
    117    return entry;
    118  }
    119 
    120  notifyObservers(observer = null) {
    121    let entries = [];
    122    if (this.prefValue) {
    123      for (let prefEntry of this.prefValue.split(",")) {
    124        let entry = Feature.rsObjectToEntry({
    125          category: "internal-pref",
    126          urlPattern: prefEntry,
    127          classifierFeatures: [this.name],
    128        });
    129        if (entry) {
    130          entries.push(entry);
    131        }
    132      }
    133    }
    134 
    135    if (this.remoteEntries) {
    136      for (let entry of this.remoteEntries) {
    137        entries.push(entry);
    138      }
    139    }
    140 
    141    // Construct nsIUrlClassifierExceptionList with all entries that belong to
    142    // this feature.
    143    let list = Cc[
    144      "@mozilla.org/url-classifier/exception-list;1"
    145    ].createInstance(Ci.nsIUrlClassifierExceptionList);
    146    for (let entry of entries) {
    147      try {
    148        list.addEntry(entry);
    149      } catch (e) {
    150        console.error(
    151          "Error adding url classifier exception list entry " + e.message,
    152          e,
    153          entry
    154        );
    155      }
    156    }
    157 
    158    if (observer) {
    159      observer.onExceptionListUpdate(list);
    160    } else {
    161      for (let obs of this.observers) {
    162        obs.onExceptionListUpdate(list);
    163      }
    164    }
    165  }
    166 }
    167 
    168 UrlClassifierExceptionListService.prototype = {
    169  classID: Components.ID("{b9f4fd03-9d87-4bfd-9958-85a821750ddc}"),
    170  QueryInterface: ChromeUtils.generateQI([
    171    "nsIUrlClassifierExceptionListService",
    172    "nsIObserver",
    173  ]),
    174 
    175  features: {},
    176  _initialized: false,
    177 
    178  ETP_PREFERENCES: [
    179    "privacy.trackingprotection.allow_list.baseline.enabled",
    180    "privacy.trackingprotection.allow_list.convenience.enabled",
    181    "browser.contentblocking.category",
    182  ],
    183  PREF_ALLOW_LIST_USER_INTERACTED:
    184    "privacy.trackingprotection.allow_list.hasUserInteractedWithETPSettings",
    185 
    186  observe(subject, topic, data) {
    187    if (topic === "idle-daily") {
    188      const baseline = Services.prefs.getBoolPref(
    189        "privacy.trackingprotection.allow_list.baseline.enabled"
    190      );
    191      const convenience = Services.prefs.getBoolPref(
    192        "privacy.trackingprotection.allow_list.convenience.enabled"
    193      );
    194      Glean.contentblocking.tpAllowlistBaselineEnabled.set(baseline);
    195      // If baseline is false, having convenience as true has no effect, so we treat it as false.
    196      Glean.contentblocking.tpAllowlistConvenienceEnabled.set(
    197        baseline ? convenience : false
    198      );
    199    }
    200    if (topic === "nsPref:changed") {
    201      // If the user changes the baseline, convenience, or category preference, we set
    202      // hasUserInteractedWithETP to true to indicate interaction with ETP settings.
    203      // This lets us skip the infobar prompting users to enable allowlists if they’ve
    204      // already made a choice.
    205      if (this.ETP_PREFERENCES.includes(data)) {
    206        Services.prefs.setBoolPref(this.PREF_ALLOW_LIST_USER_INTERACTED, true);
    207      }
    208    }
    209  },
    210 
    211  async lazyInit() {
    212    if (this._initialized) {
    213      return;
    214    }
    215 
    216    this.maybeMigrateCategoryPrefs();
    217 
    218    // Add ETP preference observers AFTER migration to avoid false positives. The migration function
    219    // above may programmatically change ETP preferences, which would incorrectly trigger our user
    220    // interaction tracking if observers were already installed. By adding observers after
    221    // migration, we ensure we only detect user changes to ETP settings.
    222    this.addETPUserInteractionPrefObservers();
    223 
    224    let rs = lazy.RemoteSettings(COLLECTION_NAME);
    225    rs.on("sync", event => {
    226      let {
    227        data: { current },
    228      } = event;
    229      this.entries = current || [];
    230      this.onUpdateEntries(current);
    231    });
    232 
    233    this._initialized = true;
    234 
    235    // If the remote settings list hasn't been populated yet we have to make sure
    236    // to do it before firing the first notification.
    237    // This has to be run after _initialized is set because we'll be
    238    // blocked while getting entries from RemoteSetting, and we don't want
    239    // LazyInit is executed again.
    240    try {
    241      // The data will be initially available from the local DB (via a
    242      // resource:// URI).
    243      this.entries = await rs.get();
    244    } catch (e) {}
    245 
    246    // RemoteSettings.get() could return null, ensure passing a list to
    247    // onUpdateEntries.
    248    if (!this.entries) {
    249      this.entries = [];
    250    }
    251 
    252    this.onUpdateEntries(this.entries);
    253  },
    254 
    255  /**
    256   * Runs migration code for the allow-list category prefs.
    257   * Users who have ETP "strict" or "custom" enabled should not automatically
    258   * get enrolled into the new allow-list categories. Instead they should have
    259   * the opportunity to opt in/out via the preferences UI.
    260   */
    261  maybeMigrateCategoryPrefs() {
    262    const ALLOW_LIST_CATEGORY_MIGRATION_PREF =
    263      "privacy.trackingprotection.allow_list.hasMigratedCategoryPrefs";
    264 
    265    if (Services.prefs.getBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, false)) {
    266      // Already migrated.
    267      return;
    268    }
    269 
    270    // Set the migration pref to true so we only run the migration once.
    271    Services.prefs.setBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, true);
    272 
    273    // This pref is set on both Desktop and Fenix (Bug 1956620).
    274    let cbCategory = Services.prefs.getStringPref(
    275      "browser.contentblocking.category",
    276      "standard"
    277    );
    278    // Don't migrate if the user is using the default category. The default
    279    // category pref states are already correct.
    280    if (cbCategory == "standard") {
    281      return;
    282    }
    283 
    284    // cbCategory is either "strict" or "custom". Disable both allow list
    285    // categories.
    286    Services.prefs.setBoolPref(
    287      "privacy.trackingprotection.allow_list.baseline.enabled",
    288      false
    289    );
    290    Services.prefs.setBoolPref(
    291      "privacy.trackingprotection.allow_list.convenience.enabled",
    292      false
    293    );
    294  },
    295 
    296  onUpdateEntries(entries) {
    297    for (let key of Object.keys(this.features)) {
    298      let feature = this.features[key];
    299      feature.onRemoteSettingsUpdate(entries);
    300      feature.notifyObservers();
    301    }
    302  },
    303 
    304  registerAndRunExceptionListObserver(feature, prefName, observer) {
    305    // We don't await this; the caller is C++ and won't await this function,
    306    // and because we prevent re-entering into this method, once it's been
    307    // called once any subsequent calls will early-return anyway - so
    308    // awaiting that would be meaningless. Instead, `Feature` implementations
    309    // make sure not to call into observers until they have data, and we
    310    // make sure to let feature instances know whether we have data
    311    // immediately.
    312    this.lazyInit();
    313 
    314    if (!this.features[feature]) {
    315      let featureObj = new Feature(feature, prefName);
    316      this.features[feature] = featureObj;
    317      // If we've previously initialized, we need to pass the entries
    318      // we already have to the new feature.
    319      if (this.entries) {
    320        featureObj.onRemoteSettingsUpdate(this.entries);
    321      }
    322    }
    323    this.features[feature].addAndRunObserver(observer);
    324  },
    325 
    326  unregisterExceptionListObserver(feature, observer) {
    327    if (!this.features[feature]) {
    328      return;
    329    }
    330    this.features[feature].removeObserver(observer);
    331  },
    332 
    333  /**
    334   * Adds preference observers to track user interactions with ETP settings.
    335   * These observers monitor changes to the baseline allow list, convenience allow list, and
    336   * content blocking category preferences to detect when users modify ETP-related settings.
    337   */
    338  addETPUserInteractionPrefObservers() {
    339    this.ETP_PREFERENCES.forEach(pref => {
    340      Services.prefs.addObserver(pref, this.observe.bind(this));
    341    });
    342  },
    343 
    344  removeETPUserInteractionPrefObservers() {
    345    this.ETP_PREFERENCES.forEach(pref => {
    346      Services.prefs.removeObserver(pref, this.observe.bind(this));
    347    });
    348  },
    349 
    350  clear() {
    351    this.features = {};
    352    this._initialized = false;
    353    this.entries = null;
    354    this.removeETPUserInteractionPrefObservers();
    355  },
    356 };