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 };