shims.js (41875B)
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, module, onMessageFromTab */ 8 9 // To grant shims access to bundled logo images without risking 10 // exposing our moz-extension URL, we have the shim request them via 11 // nonsense URLs which we then redirect to the actual files (but only 12 // on tabs where a shim using a given logo happens to be active). 13 const LogosBaseURL = "https://smartblock.firefox.etp/"; 14 15 const loggingPrefValue = browser.aboutConfigPrefs.getPref( 16 "disable_debug_logging" 17 ); 18 19 const releaseBranchPromise = browser.appConstants.getReleaseBranch(); 20 21 const platformPromise = browser.runtime.getPlatformInfo().then(info => { 22 return info.os === "android" ? "android" : "desktop"; 23 }); 24 25 let debug = async function () { 26 if ( 27 loggingPrefValue !== true && 28 (await releaseBranchPromise) !== "release_or_beta" 29 ) { 30 console.debug.apply(this, arguments); 31 } 32 }; 33 let error = async function () { 34 if ((await releaseBranchPromise) !== "release_or_beta") { 35 console.error.apply(this, arguments); 36 } 37 }; 38 let warn = async function () { 39 if ((await releaseBranchPromise) !== "release_or_beta") { 40 console.warn.apply(this, arguments); 41 } 42 }; 43 44 class Shim { 45 constructor(opts, manager) { 46 this.manager = manager; 47 48 const { contentScripts, matches, unblocksOnOptIn } = opts; 49 50 this.branches = opts.branches; 51 this.bug = opts.bug; 52 this.isGoogleTrendsDFPIFix = opts.custom == "google-trends-dfpi-fix"; 53 this.file = opts.file; 54 this.hiddenInAboutCompat = opts.hiddenInAboutCompat; 55 this.hosts = opts.hosts; 56 this.id = opts.id; 57 this.isMissingFiles = opts.isMissingFiles; 58 this.logos = opts.logos || []; 59 this.matches = []; 60 this.name = opts.name; 61 this.notHosts = opts.notHosts; 62 this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP; 63 this.onlyIfDFPIActive = opts.onlyIfDFPIActive; 64 this.onlyIfPrivateBrowsing = opts.onlyIfPrivateBrowsing; 65 this._options = opts.options || {}; 66 this.webExposedShimHelpers = opts.webExposedShimHelpers; 67 this.needsShimHelpers = opts.needsShimHelpers; 68 this.platform = opts.platform || "all"; 69 this.runFirst = opts.runFirst; 70 this.unblocksOnOptIn = unblocksOnOptIn; 71 this.requestStorageAccessForRedirect = opts.requestStorageAccessForRedirect; 72 this.shouldUseScriptingAPI = browser.aboutConfigPrefs.getPref( 73 "useScriptingAPI", 74 false 75 ); 76 this.isSmartblockEmbedShim = opts.isSmartblockEmbedShim || false; 77 debug( 78 `WebCompat Shim ${this.id} will be injected using ${ 79 this.shouldUseScriptingAPI ? "scripting" : "contentScripts" 80 } API` 81 ); 82 83 this._hostOptIns = new Set(); 84 this._pBModeHostOptIns = new Set(); 85 86 this._disabledByConfig = opts.disabled; 87 this._disabledGlobally = false; 88 this._disabledForSession = false; 89 this._disabledByPlatform = false; 90 this._disabledByReleaseBranch = false; 91 this._disabledBySmartblockEmbedPref = false; 92 93 this._activeOnTabs = new Set(); 94 this._showedOptInOnTabs = new Set(); 95 96 const pref = `disabled_shims.${this.id}`; 97 98 this.redirectsRequests = !!this.file && matches?.length; 99 100 // NOTE: _contentScriptRegistrations is an array of string ids when 101 // shouldUseScriptingAPI is true and an array of script handles returned 102 // by contentScripts.register otherwise. 103 this._contentScriptRegistrations = []; 104 105 this.contentScripts = contentScripts || []; 106 for (const script of this.contentScripts) { 107 if (typeof script.css === "string") { 108 script.css = [ 109 this.shouldUseScriptingAPI 110 ? `/shims/${script.css}` 111 : { file: `/shims/${script.css}` }, 112 ]; 113 } 114 if (typeof script.js === "string") { 115 script.js = [ 116 this.shouldUseScriptingAPI 117 ? `/shims/${script.js}` 118 : { file: `/shims/${script.js}` }, 119 ]; 120 } 121 } 122 123 for (const match of matches || []) { 124 if (!match.types) { 125 this.matches.push({ patterns: [match], types: ["script"] }); 126 } else { 127 this.matches.push(match); 128 } 129 if (match.target) { 130 this.redirectsRequests = true; 131 } 132 } 133 134 browser.aboutConfigPrefs.onPrefChange.addListener(async () => { 135 const value = browser.aboutConfigPrefs.getPref(pref); 136 this._disabledPrefValue = value; 137 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 138 }, pref); 139 140 this._disabledPrefValue = browser.aboutConfigPrefs.getPref(pref); 141 this.ready = Promise.all([platformPromise, releaseBranchPromise]).then( 142 ([platform, branch]) => { 143 this._disabledByPlatform = 144 this.platform !== "all" && this.platform !== platform; 145 146 this._disabledByReleaseBranch = false; 147 for (const supportedBranchAndPlatform of this.branches || []) { 148 const [supportedBranch, supportedPlatform] = 149 supportedBranchAndPlatform.split(":"); 150 if ( 151 (!supportedPlatform || supportedPlatform == platform) && 152 supportedBranch != branch 153 ) { 154 this._disabledByReleaseBranch = true; 155 } 156 } 157 158 this._preprocessOptions(platform, branch); 159 this._onEnabledStateChanged(); 160 } 161 ); 162 } 163 164 _preprocessOptions(platform, branch) { 165 // options may be any value, but can optionally be gated for specified 166 // platform/branches, if in the format `{value, branches, platform}` 167 this.options = {}; 168 for (const [k, v] of Object.entries(this._options)) { 169 if (v?.value) { 170 if ( 171 (!v.platform || v.platform === platform) && 172 (!v.branches || v.branches.includes(branch)) 173 ) { 174 this.options[k] = v.value; 175 } 176 } else { 177 this.options[k] = v; 178 } 179 } 180 } 181 182 get enabled() { 183 if (this.isMissingFiles) { 184 return false; 185 } 186 187 if (this._disabledGlobally || this._disabledForSession) { 188 return false; 189 } 190 191 if (this._disabledPrefValue !== undefined) { 192 return !this._disabledPrefValue; 193 } 194 195 if (this.isSmartblockEmbedShim && this._disabledBySmartblockEmbedPref) { 196 return false; 197 } 198 199 return ( 200 !this._disabledByConfig && 201 !this._disabledByPlatform && 202 !this._disabledByReleaseBranch 203 ); 204 } 205 206 get disabledReason() { 207 if (this.isMissingFiles) { 208 return "missingFiles"; 209 } 210 211 if (this._disabledGlobally) { 212 return "globalPref"; 213 } 214 215 if (this._disabledForSession) { 216 return "session"; 217 } 218 219 if (this._disabledPrefValue !== undefined) { 220 if (this._disabledPrefValue === true) { 221 return "pref"; 222 } 223 return false; 224 } 225 226 if (this.isSmartblockEmbedShim && this._disabledBySmartblockEmbedPref) { 227 return "smartblockEmbedDisabledByPref"; 228 } 229 230 if (this._disabledByConfig) { 231 return "config"; 232 } 233 234 if (this._disabledByPlatform) { 235 return "platform"; 236 } 237 238 if (this._disabledByReleaseBranch) { 239 return "releaseBranch"; 240 } 241 242 return false; 243 } 244 245 get disabledBySmartblockEmbedPref() { 246 return this._disabledBySmartblockEmbedPref; 247 } 248 249 set disabledBySmartblockEmbedPref(value) { 250 this._disabledBySmartblockEmbedPref = value; 251 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 252 } 253 254 onAllShimsEnabled() { 255 const wasEnabled = this.enabled; 256 this._disabledGlobally = false; 257 if (!wasEnabled) { 258 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 259 } 260 } 261 262 onAllShimsDisabled() { 263 const wasEnabled = this.enabled; 264 this._disabledGlobally = true; 265 if (wasEnabled) { 266 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 267 } 268 } 269 270 enableForSession() { 271 const wasEnabled = this.enabled; 272 this._disabledForSession = false; 273 if (!wasEnabled) { 274 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 275 } 276 } 277 278 disableForSession() { 279 const wasEnabled = this.enabled; 280 this._disabledForSession = true; 281 if (wasEnabled) { 282 this._onEnabledStateChanged({ alsoClearResourceCache: true }); 283 } 284 } 285 286 async _onEnabledStateChanged({ alsoClearResourceCache = false } = {}) { 287 this.manager?.onShimStateChanged(this.id); 288 if (!this.enabled) { 289 await this._unregisterContentScripts(); 290 return this._revokeRequestsInETP(alsoClearResourceCache); 291 } 292 await this._registerContentScripts(); 293 return this._allowRequestsInETP(alsoClearResourceCache); 294 } 295 296 async _registerContentScripts() { 297 if ( 298 this.contentScripts.length && 299 !this._contentScriptRegistrations.length 300 ) { 301 const matches = []; 302 let idx = 0; 303 for (const options of this.contentScripts) { 304 matches.push(options.matches); 305 if (this.shouldUseScriptingAPI) { 306 // Some shims includes more than one script (e.g. Blogger one contains 307 // a content script to be run on document_start and one to be run 308 // on document_end. 309 options.id = `shim-${this.id}-${idx++}`; 310 options.persistAcrossSessions = false; 311 // Having to call getRegisteredContentScripts each time we are going to 312 // register a Shim content script is suboptimal, but avoiding that 313 // may require a bit more changes (e.g. rework both Injections, Shim and Shims 314 // classes to more easily register all content scripts with a single 315 // call to the scripting API methods when the background script page is loading 316 // and one per injection or shim being enabled from the AboutCompatBroker). 317 // In the short term we call getRegisteredContentScripts and restrict it to 318 // the script id we are about to register. 319 let isAlreadyRegistered = false; 320 try { 321 const registeredScripts = 322 await browser.scripting.getRegisteredContentScripts({ 323 ids: [options.id], 324 }); 325 isAlreadyRegistered = !!registeredScripts.length; 326 } catch (ex) { 327 console.error( 328 "Retrieve WebCompat GoFaster registered content scripts failed: ", 329 ex 330 ); 331 } 332 try { 333 if (!isAlreadyRegistered) { 334 await browser.scripting.registerContentScripts([options]); 335 } 336 this._contentScriptRegistrations.push(options.id); 337 } catch (ex) { 338 console.error( 339 "Registering WebCompat Shim content scripts failed: ", 340 options, 341 ex 342 ); 343 } 344 } else { 345 const reg = await browser.contentScripts.register(options); 346 this._contentScriptRegistrations.push(reg); 347 } 348 } 349 const urls = Array.from(new Set(matches.flat())); 350 debug("Enabling content scripts for these URLs:", urls); 351 } 352 } 353 354 async _unregisterContentScripts() { 355 if (this.shouldUseScriptingAPI) { 356 for (const id of this._contentScriptRegistrations) { 357 try { 358 await browser.scripting.unregisterContentScripts({ ids: [id] }); 359 } catch (_) {} 360 } 361 } else { 362 for (const registration of this._contentScriptRegistrations) { 363 registration.unregister(); 364 } 365 } 366 this._contentScriptRegistrations = []; 367 } 368 369 async _allowRequestsInETP(alsoClearResourceCache) { 370 let modified = false; 371 const matches = this.matches.map(m => m.patterns).flat(); 372 if (matches.length) { 373 // ensure requests shimmed in both PB and non-PB modes 374 await browser.trackingProtection.shim(this.id, matches); 375 modified = true; 376 } 377 378 if (this._hostOptIns.size) { 379 const optIns = this.getApplicableOptIns(); 380 if (optIns.length) { 381 await browser.trackingProtection.allow( 382 this.id, 383 this._optInPatterns, 384 false, 385 Array.from(this._hostOptIns) 386 ); 387 modified = true; 388 } 389 } 390 391 if (this._pBModeHostOptIns.size) { 392 const optIns = this.getApplicableOptIns(); 393 if (optIns.length) { 394 await browser.trackingProtection.allow( 395 this.id, 396 this._optInPatterns, 397 true, 398 Array.from(this._pBModeHostOptIns) 399 ); 400 modified = true; 401 } 402 } 403 404 if (this._haveCheckedEnabledPrefs && alsoClearResourceCache && modified) { 405 this.clearResourceCache(); 406 } 407 } 408 409 async _revokeRequestsInETP(alsoClearResourceCache) { 410 await browser.trackingProtection.revoke(this.id); 411 if (this._haveCheckedEnabledPrefs && alsoClearResourceCache) { 412 this.clearResourceCache(); 413 } 414 } 415 416 setActiveOnTab(tabId, active = true) { 417 if (active) { 418 this._activeOnTabs.add(tabId); 419 } else { 420 this._activeOnTabs.delete(tabId); 421 this._showedOptInOnTabs.delete(tabId); 422 } 423 } 424 425 isActiveOnTab(tabId) { 426 return this._activeOnTabs.has(tabId); 427 } 428 429 meantForHost(host) { 430 const { hosts, notHosts } = this; 431 if (hosts || notHosts) { 432 if ( 433 (notHosts && notHosts.includes(host)) || 434 (hosts && !hosts.includes(host)) 435 ) { 436 return false; 437 } 438 } 439 return true; 440 } 441 442 async unblocksURLOnOptIn(url) { 443 if (!this._optInPatterns) { 444 this._optInPatterns = await this.getApplicableOptIns(); 445 } 446 447 if (!this._optInMatcher) { 448 this._optInMatcher = browser.matchPatterns.getMatcher( 449 Array.from(this._optInPatterns) 450 ); 451 } 452 453 return this._optInMatcher.matches(url); 454 } 455 456 isTriggeredByURLAndType(url, type) { 457 for (const entry of this.matches || []) { 458 if (!entry.types.includes(type)) { 459 continue; 460 } 461 if (!entry.matcher) { 462 entry.matcher = browser.matchPatterns.getMatcher( 463 Array.from(entry.patterns) 464 ); 465 } 466 if (entry.matcher.matches(url)) { 467 return entry; 468 } 469 } 470 471 return undefined; 472 } 473 474 async getApplicableOptIns() { 475 if (this._applicableOptIns) { 476 return this._applicableOptIns; 477 } 478 const optins = []; 479 for (const unblock of this.unblocksOnOptIn || []) { 480 if (typeof unblock === "string") { 481 optins.push(unblock); 482 continue; 483 } 484 const { branches, patterns, platforms } = unblock; 485 if (platforms?.length) { 486 const platform = await platformPromise; 487 if (platform !== "all" && !platforms.includes(platform)) { 488 continue; 489 } 490 } 491 if (branches?.length) { 492 const branch = await releaseBranchPromise; 493 if (!branches.includes(branch)) { 494 continue; 495 } 496 } 497 optins.push.apply(optins, patterns); 498 } 499 this._applicableOptIns = optins; 500 return optins; 501 } 502 503 async onUserOptIn(host, isPrivateMode) { 504 const optins = await this.getApplicableOptIns(); 505 const activeHostOptIns = isPrivateMode 506 ? this._pBModeHostOptIns 507 : this._hostOptIns; 508 if (optins.length) { 509 activeHostOptIns.add(host); 510 await browser.trackingProtection.allow( 511 this.id, 512 optins, 513 isPrivateMode, 514 Array.from(activeHostOptIns) 515 ); 516 this.clearResourceCache(); 517 } 518 } 519 520 hasUserOptedInAlready(host, isPrivateMode) { 521 const activeHostOptIns = isPrivateMode 522 ? this._pBModeHostOptIns 523 : this._hostOptIns; 524 return activeHostOptIns.has(host); 525 } 526 527 showOptInWarningOnce(tabId, origin) { 528 if (this._showedOptInOnTabs.has(tabId)) { 529 return Promise.resolve(); 530 } 531 this._showedOptInOnTabs.add(tabId); 532 533 const { bug, name } = this; 534 const warning = `${name} is allowed on ${origin} for this browsing session due to user opt-in. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; 535 return browser.tabs 536 .executeScript(tabId, { 537 code: `console.warn(${JSON.stringify(warning)})`, 538 runAt: "document_start", 539 }) 540 .catch(() => {}); 541 } 542 543 async onUserOptOut(host, isPrivateMode) { 544 const optIns = await this.getApplicableOptIns(); 545 const activeHostOptIns = isPrivateMode 546 ? this._pBModeHostOptIns 547 : this._hostOptIns; 548 if (optIns.length) { 549 activeHostOptIns.delete(host); 550 await browser.trackingProtection.allow( 551 this.id, 552 optIns, 553 isPrivateMode, 554 Array.from(activeHostOptIns) 555 ); 556 this.clearResourceCache(); 557 } 558 } 559 560 async clearUserOptIns(forPrivateMode) { 561 const optIns = await this.getApplicableOptIns(); 562 const activeHostOptIns = forPrivateMode 563 ? this._pBModeHostOptIns 564 : this._hostOptIns; 565 if (optIns.length) { 566 activeHostOptIns.clear(); 567 await browser.trackingProtection.allow( 568 this.id, 569 optIns, 570 forPrivateMode, 571 Array.from(activeHostOptIns) 572 ); 573 this.clearResourceCache(); 574 } 575 } 576 577 clearResourceCache() { 578 return browser.trackingProtection.clearResourceCache(); 579 } 580 } 581 582 class Shims { 583 constructor(availableShims) { 584 this._originalShims = availableShims; 585 586 if (!browser.trackingProtection) { 587 console.error("Required experimental add-on APIs for shims unavailable"); 588 return; 589 } 590 591 this._readyPromise = new Promise(done => (this._resolveReady = done)); 592 this._registerShims(availableShims); 593 594 onMessageFromTab(this._onMessageFromShim.bind(this)); 595 596 this.ENABLED_PREF = "enable_shims"; 597 browser.aboutConfigPrefs.onPrefChange.addListener(() => { 598 this._checkEnabledPref(); 599 }, this.ENABLED_PREF); 600 601 this.SMARTBLOCK_EMBEDS_ENABLED_PREF = `smartblockEmbeds.enabled`; 602 browser.aboutConfigPrefs.onPrefChange.addListener(() => { 603 this._checkSmartblockEmbedsEnabledPref(); 604 }, this.SMARTBLOCK_EMBEDS_ENABLED_PREF); 605 606 // NOTE: Methods that uses the prefs should await 607 // _haveCheckedEnabledPrefsPromise, in order to make sure the 608 // prefs are all read. 609 // Methods that potentially clears the resource cache should check 610 // _haveCheckedEnabledPrefs, in order to avoid clearing the 611 // resource cache during the startup. 612 this._haveCheckedEnabledPrefs = false; 613 this._haveCheckedEnabledPrefsPromise = Promise.all([ 614 this._checkEnabledPref(), 615 this._checkSmartblockEmbedsEnabledPref(), 616 ]); 617 this._haveCheckedEnabledPrefsPromise.then(() => { 618 this._haveCheckedEnabledPrefs = true; 619 }); 620 621 // handles unblock message coming in from protections panel 622 browser.trackingProtection.onSmartBlockEmbedUnblock.addListener( 623 async (tabId, shimId, hostname) => { 624 const shim = this.shims.get(shimId); 625 if (!shim) { 626 console.warn("Smartblock shim not found", { tabId, shimId }); 627 return; 628 } 629 const isPB = (await browser.tabs.get(tabId)).incognito; 630 await shim.onUserOptIn(hostname, isPB); 631 632 // send request to shim to remove placeholders and replace with original embeds 633 await browser.tabs.sendMessage(tabId, { 634 shimId, 635 topic: "smartblock:unblock-embed", 636 }); 637 } 638 ); 639 640 // handles reblock message coming in from protections panel 641 browser.trackingProtection.onSmartBlockEmbedReblock.addListener( 642 async (tabId, shimId, hostname) => { 643 const shim = this.shims.get(shimId); 644 if (!shim) { 645 console.warn("Smartblock shim not found", { tabId, shimId }); 646 return; 647 } 648 const isPB = (await browser.tabs.get(tabId)).incognito; 649 await shim.onUserOptOut(hostname, isPB); 650 651 // a browser reload is required to reload the shim in the case where the shim gets unloaded 652 // i.e. after user unblocks, then closes and revisits the page while shim is still allowed 653 browser.tabs.reload(tabId); 654 } 655 ); 656 657 // handles data clearing on private browsing mode end 658 browser.trackingProtection.onPrivateSessionEnd.addListener(() => { 659 for (const shim of this.shims.values()) { 660 shim.clearUserOptIns(true); 661 } 662 }); 663 } 664 665 ready() { 666 return this._readyPromise; 667 } 668 669 bindAboutCompatBroker(broker) { 670 this._aboutCompatBroker = broker; 671 } 672 673 getShimInfoForAboutCompat(shim) { 674 const { bug, disabledReason, hiddenInAboutCompat, id, name } = shim; 675 const type = "smartblock"; 676 return { bug, disabledReason, hidden: hiddenInAboutCompat, id, name, type }; 677 } 678 679 disableShimForSession(id) { 680 const shim = this.shims.get(id); 681 shim?.disableForSession(); 682 } 683 684 enableShimForSession(id) { 685 const shim = this.shims.get(id); 686 shim?.enableForSession(); 687 } 688 689 onShimStateChanged(id) { 690 if (!this._aboutCompatBroker) { 691 return; 692 } 693 694 const shim = this.shims.get(id); 695 if (!shim) { 696 return; 697 } 698 699 const shimsChanged = [this.getShimInfoForAboutCompat(shim)]; 700 this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ shimsChanged }); 701 } 702 703 getAvailableShims() { 704 const shims = Array.from(this.shims.values()).map( 705 this.getShimInfoForAboutCompat 706 ); 707 shims.sort((a, b) => a.name.localeCompare(b.name)); 708 return shims; 709 } 710 711 async onRemoteSettingsUpdate(updatedShims) { 712 const oldReadyPromise = this._readyPromise; 713 this._readyPromise = new Promise(done => (this._resolveReady = done)); 714 await oldReadyPromise; 715 this._updateShims(updatedShims); 716 } 717 718 async _updateShims(updatedShims) { 719 await this._unregisterShims(); 720 this._registerShims(updatedShims); 721 this._checkEnabledPref(); 722 await this.ready(); 723 } 724 725 async _resetToDefaultShims() { 726 await this._updateShims(this._originalShims); 727 } 728 729 _registerShims(shims) { 730 if (this.shims) { 731 throw new Error("_registerShims has already been called"); 732 } 733 734 this._registeredShimListeners = []; 735 const registerShimListener = (api, listener, ...args) => { 736 api.addListener(listener, ...args); 737 this._registeredShimListeners.push([api, listener]); 738 }; 739 740 this.shims = new Map(); 741 for (const shimOpts of shims) { 742 const { id } = shimOpts; 743 if (!this.shims.has(id)) { 744 this.shims.set(shimOpts.id, new Shim(shimOpts, this)); 745 } 746 } 747 748 // Register onBeforeRequest listener which handles storage access requests 749 // on matching redirects. 750 let redirectTargetUrls = Array.from(shims.values()) 751 .filter( 752 shim => !shim.isMissingFiles && shim.requestStorageAccessForRedirect 753 ) 754 .flatMap(shim => shim.requestStorageAccessForRedirect) 755 .map(([, dstUrl]) => dstUrl); 756 757 // Unique target urls. 758 redirectTargetUrls = Array.from(new Set(redirectTargetUrls)); 759 760 if (redirectTargetUrls.length) { 761 debug("Registering redirect listener for requestStorageAccess helper", { 762 redirectTargetUrls, 763 }); 764 registerShimListener( 765 browser.webRequest.onBeforeRequest, 766 this._onRequestStorageAccessRedirect.bind(this), 767 { urls: redirectTargetUrls, types: ["main_frame"] }, 768 ["blocking"] 769 ); 770 } 771 772 function addTypePatterns(type, patterns, set) { 773 if (!set.has(type)) { 774 set.set(type, { patterns: new Set() }); 775 } 776 const allSet = set.get(type).patterns; 777 for (const pattern of patterns) { 778 allSet.add(pattern); 779 } 780 } 781 782 const allMatchTypePatterns = new Map(); 783 const allHeaderChangingMatchTypePatterns = new Map(); 784 const allLogos = []; 785 for (const shim of this.shims.values()) { 786 if (shim.isMissingFiles) { 787 continue; 788 } 789 const { logos, matches } = shim; 790 allLogos.push(...logos); 791 for (const { patterns, target, types } of matches || []) { 792 for (const type of types) { 793 if (shim.isGoogleTrendsDFPIFix) { 794 addTypePatterns(type, patterns, allHeaderChangingMatchTypePatterns); 795 } 796 if (target || shim.file || shim.runFirst) { 797 addTypePatterns(type, patterns, allMatchTypePatterns); 798 } 799 } 800 } 801 } 802 803 if (allLogos.length) { 804 const urls = Array.from(new Set(allLogos)).map(l => { 805 return `${LogosBaseURL}${l}`; 806 }); 807 debug("Allowing access to these logos:", urls); 808 const unmarkShimsActive = tabId => { 809 for (const shim of this.shims.values()) { 810 shim.setActiveOnTab(tabId, false); 811 } 812 }; 813 registerShimListener(browser.tabs.onRemoved, unmarkShimsActive); 814 registerShimListener(browser.tabs.onUpdated, (tabId, changeInfo) => { 815 if (changeInfo.discarded || changeInfo.url) { 816 unmarkShimsActive(tabId); 817 } 818 }); 819 registerShimListener( 820 browser.webRequest.onBeforeRequest, 821 this._redirectLogos.bind(this), 822 { urls, types: ["image"] }, 823 ["blocking"] 824 ); 825 } 826 827 if (allHeaderChangingMatchTypePatterns) { 828 for (const [ 829 type, 830 { patterns }, 831 ] of allHeaderChangingMatchTypePatterns.entries()) { 832 const urls = Array.from(patterns); 833 debug("Shimming these", type, "URLs:", urls); 834 registerShimListener( 835 browser.webRequest.onBeforeSendHeaders, 836 this._onBeforeSendHeaders.bind(this), 837 { urls, types: [type] }, 838 ["blocking", "requestHeaders"] 839 ); 840 registerShimListener( 841 browser.webRequest.onHeadersReceived, 842 this._onHeadersReceived.bind(this), 843 { urls, types: [type] }, 844 ["blocking", "responseHeaders"] 845 ); 846 } 847 } 848 849 if (!allMatchTypePatterns.size) { 850 debug("Skipping shims; none enabled"); 851 return; 852 } 853 854 for (const [type, { patterns }] of allMatchTypePatterns.entries()) { 855 const urls = Array.from(patterns); 856 debug("Shimming these", type, "URLs:", urls); 857 858 registerShimListener( 859 browser.webRequest.onBeforeRequest, 860 this._ensureShimForRequestOnTab.bind(this), 861 { urls, types: [type] }, 862 ["blocking"] 863 ); 864 } 865 } 866 867 _unregisterShims() { 868 this.enabled = false; 869 if (this._registeredShimListeners) { 870 for (let [api, listener] of this._registeredShimListeners) { 871 api.removeListener(listener); 872 } 873 this._registeredShimListeners = undefined; 874 } 875 this.shims = undefined; 876 } 877 878 async _checkEnabledPref() { 879 const value = browser.aboutConfigPrefs.getPref(this.ENABLED_PREF); 880 if (value === undefined) { 881 await browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true); 882 } else if (value === false) { 883 this.enabled = false; 884 } else { 885 this.enabled = true; 886 } 887 } 888 889 get enabled() { 890 return this._enabled; 891 } 892 893 set enabled(enabled) { 894 if (enabled === this._enabled) { 895 return; 896 } 897 898 // resolveReady may change while we're updating 899 const resolveReady = this._resolveReady; 900 this._enabled = enabled; 901 902 for (const shim of this.shims.values()) { 903 if (enabled) { 904 shim.onAllShimsEnabled(); 905 } else { 906 shim.onAllShimsDisabled(); 907 } 908 } 909 resolveReady(); 910 } 911 912 async _checkSmartblockEmbedsEnabledPref() { 913 const value = browser.aboutConfigPrefs.getPref( 914 this.SMARTBLOCK_EMBEDS_ENABLED_PREF 915 ); 916 if (value === undefined) { 917 await browser.aboutConfigPrefs.setPref( 918 this.SMARTBLOCK_EMBEDS_ENABLED_PREF, 919 true 920 ); 921 } else if (value === false) { 922 this.smartblockEmbedsEnabled = false; 923 } else { 924 this.smartblockEmbedsEnabled = true; 925 } 926 } 927 928 get smartblockEmbedsEnabled() { 929 return this._smartblockEmbedsEnabled; 930 } 931 932 set smartblockEmbedsEnabled(value) { 933 if (value === this._smartblockEmbedsEnabled) { 934 return; 935 } 936 937 this._smartblockEmbedsEnabled = value; 938 939 for (const shim of this.shims.values()) { 940 if (shim.isSmartblockEmbedShim) { 941 shim.disabledBySmartblockEmbedPref = !this._smartblockEmbedsEnabled; 942 } 943 } 944 } 945 946 async _onRequestStorageAccessRedirect({ 947 originUrl: srcUrl, 948 url: dstUrl, 949 tabId, 950 }) { 951 debug("Detected redirect", { srcUrl, dstUrl, tabId }); 952 953 // Check if a shim needs to request storage access for this redirect. This 954 // handler is called when the *source url* matches a shims redirect pattern, 955 // but we still need to check if the *destination url* matches. 956 const matchingShims = Array.from(this.shims.values()).filter(shim => { 957 const { enabled, requestStorageAccessForRedirect } = shim; 958 959 if (!enabled || !requestStorageAccessForRedirect) { 960 return false; 961 } 962 963 return requestStorageAccessForRedirect.some( 964 ([srcPattern, dstPattern]) => 965 browser.matchPatterns.getMatcher([srcPattern]).matches(srcUrl) && 966 browser.matchPatterns.getMatcher([dstPattern]).matches(dstUrl) 967 ); 968 }); 969 970 // For each matching shim, find out if its enabled in regard to dFPI state. 971 const bugNumbers = new Set(); 972 let isDFPIActive = null; 973 await Promise.all( 974 matchingShims.map(async shim => { 975 if (shim.onlyIfDFPIActive) { 976 // Only get the dFPI state for the first shim which requires it. 977 if (isDFPIActive === null) { 978 const tabIsPB = (await browser.tabs.get(tabId)).incognito; 979 isDFPIActive = 980 await browser.trackingProtection.isDFPIActive(tabIsPB); 981 } 982 if (!isDFPIActive) { 983 return; 984 } 985 } 986 bugNumbers.add(shim.bug); 987 }) 988 ); 989 990 // If there is no shim which needs storage access for this redirect src/dst 991 // pair, resume it. 992 if (!bugNumbers.size) { 993 return; 994 } 995 996 // Inject the helper to call requestStorageAccessForOrigin on the document. 997 await browser.tabs.executeScript(tabId, { 998 file: "/lib/requestStorageAccess_helper.js", 999 runAt: "document_start", 1000 }); 1001 1002 const bugUrls = Array.from(bugNumbers) 1003 .map(bugNo => `https://bugzilla.mozilla.org/show_bug.cgi?id=${bugNo}`) 1004 .join(", "); 1005 const warning = `Firefox calls the Storage Access API for ${dstUrl} on behalf of ${srcUrl}. See the following bugs for details: ${bugUrls}`; 1006 1007 // Request storage access for the origin of the destination url of the 1008 // redirect. 1009 const { origin: requestStorageAccessOrigin } = new URL(dstUrl); 1010 1011 // Wait for the requestStorageAccess request to finish before resuming the 1012 // redirect. 1013 const { success } = await browser.tabs.sendMessage(tabId, { 1014 requestStorageAccessOrigin, 1015 warning, 1016 }); 1017 debug("requestStorageAccess callback", { 1018 success, 1019 requestStorageAccessOrigin, 1020 srcUrl, 1021 dstUrl, 1022 bugNumbers, 1023 }); 1024 } 1025 1026 async _onMessageFromShim(payload, sender) { 1027 const { tab, frameId } = sender; 1028 const { id, url } = tab; 1029 const { shimId, message } = payload; 1030 1031 // Ignore unknown messages (for instance, from about:compat). 1032 if ( 1033 message !== "getOptions" && 1034 message !== "optIn" && 1035 message !== "embedClicked" && 1036 message !== "smartblockEmbedReplaced" && 1037 message !== "smartblockGetFluentString" && 1038 message !== "checkFacebookLoginStatus" 1039 ) { 1040 return undefined; 1041 } 1042 1043 if (sender.id !== browser.runtime.id || id === -1) { 1044 throw new Error("not allowed"); 1045 } 1046 1047 // Important! It is entirely possible for sites to spoof 1048 // these messages, due to shims allowing web pages to 1049 // communicate with the extension. 1050 1051 const shim = this.shims.get(shimId); 1052 if (!shim?.needsShimHelpers?.includes(message)) { 1053 throw new Error("not allowed"); 1054 } 1055 1056 if (message === "getOptions") { 1057 return Object.assign( 1058 { 1059 platform: await platformPromise, 1060 releaseBranch: await releaseBranchPromise, 1061 }, 1062 shim.options 1063 ); 1064 } else if (message === "optIn") { 1065 try { 1066 await shim.onUserOptIn(new URL(url).hostname, tab.incognito); 1067 const origin = new URL(tab.url).origin; 1068 warn( 1069 "** User opted in for", 1070 shim.name, 1071 "shim on", 1072 origin, 1073 "on tab", 1074 id, 1075 "frame", 1076 frameId 1077 ); 1078 await shim.showOptInWarningOnce(id, origin); 1079 } catch (err) { 1080 console.error(err); 1081 throw new Error("error"); 1082 } 1083 } else if (message === "embedClicked") { 1084 browser.trackingProtection.openProtectionsPanel(id); 1085 } else if (message === "smartblockEmbedReplaced") { 1086 browser.trackingProtection.incrementSmartblockEmbedShownTelemetry(); 1087 } else if (message === "smartblockGetFluentString") { 1088 return await browser.trackingProtection.getSmartBlockEmbedFluentString( 1089 id, 1090 shimId, 1091 new URL(url).hostname 1092 ); 1093 } else if (message === "checkFacebookLoginStatus") { 1094 // Verify that the user is logged in to Facebook by checking the c_user 1095 // cookie. 1096 let cookie = await browser.cookies.get({ 1097 url: "https://www.facebook.com", 1098 name: "c_user", 1099 }); 1100 1101 // If the cookie is found, the user is logged in to Facebook. 1102 return cookie != null; 1103 } 1104 1105 return undefined; 1106 } 1107 1108 async _redirectLogos(details) { 1109 await this._haveCheckedEnabledPrefsPromise; 1110 1111 if (!this.enabled) { 1112 return { cancel: true }; 1113 } 1114 1115 const { tabId, url } = details; 1116 const logo = new URL(url).pathname.slice(1); 1117 1118 for (const shim of this.shims.values()) { 1119 await shim.ready; 1120 1121 if (!shim.enabled) { 1122 continue; 1123 } 1124 1125 if (shim.onlyIfDFPIActive) { 1126 const isPB = (await browser.tabs.get(details.tabId)).incognito; 1127 if (!(await browser.trackingProtection.isDFPIActive(isPB))) { 1128 continue; 1129 } 1130 } 1131 1132 if (!shim.logos.includes(logo)) { 1133 continue; 1134 } 1135 1136 if (shim.isActiveOnTab(tabId)) { 1137 return { redirectUrl: browser.runtime.getURL(`shims/${logo}`) }; 1138 } 1139 } 1140 1141 return { cancel: true }; 1142 } 1143 1144 async _onHeadersReceived(details) { 1145 await this._haveCheckedEnabledPrefsPromise; 1146 1147 for (const shim of this.shims.values()) { 1148 await shim.ready; 1149 1150 if (!shim.enabled) { 1151 continue; 1152 } 1153 1154 if (shim.onlyIfDFPIActive) { 1155 const isPB = (await browser.tabs.get(details.tabId)).incognito; 1156 if (!(await browser.trackingProtection.isDFPIActive(isPB))) { 1157 continue; 1158 } 1159 } 1160 1161 if (shim.isGoogleTrendsDFPIFix) { 1162 if (shim.GoogleNidCookieToUse) { 1163 continue; 1164 } 1165 1166 for (const header of details.responseHeaders) { 1167 if (header.name == "set-cookie") { 1168 shim.GoogleNidCookieToUse = header.value; 1169 return { redirectUrl: details.url }; 1170 } 1171 } 1172 } 1173 } 1174 1175 return undefined; 1176 } 1177 1178 async _onBeforeSendHeaders(details) { 1179 await this._haveCheckedEnabledPrefsPromise; 1180 1181 const { frameId, requestHeaders, tabId } = details; 1182 1183 if (!this.enabled) { 1184 return { requestHeaders }; 1185 } 1186 1187 for (const shim of this.shims.values()) { 1188 await shim.ready; 1189 1190 if (!shim.enabled) { 1191 continue; 1192 } 1193 1194 if (shim.isGoogleTrendsDFPIFix) { 1195 const value = shim.GoogleNidCookieToUse; 1196 1197 if (!value) { 1198 continue; 1199 } 1200 1201 let found; 1202 for (let header of requestHeaders) { 1203 if (header.name.toLowerCase() === "cookie") { 1204 header.value = value; 1205 found = true; 1206 } 1207 } 1208 if (!found) { 1209 requestHeaders.push({ name: "Cookie", value }); 1210 } 1211 1212 browser.tabs 1213 .get(tabId) 1214 .then(({ url }) => { 1215 debug( 1216 `Google Trends dFPI fix used on tab ${tabId} frame ${frameId} (${url})` 1217 ); 1218 }) 1219 .catch(() => {}); 1220 1221 const warning = `Working around Google Trends tracking protection breakage. See https://bugzilla.mozilla.org/show_bug.cgi?id=${shim.bug} for details.`; 1222 browser.tabs 1223 .executeScript(tabId, { 1224 code: `console.warn(${JSON.stringify(warning)})`, 1225 runAt: "document_start", 1226 }) 1227 .catch(() => {}); 1228 } 1229 } 1230 1231 return { requestHeaders }; 1232 } 1233 1234 // eslint-disable-next-line complexity 1235 async _ensureShimForRequestOnTab(details) { 1236 await this._haveCheckedEnabledPrefsPromise; 1237 1238 if (!this.enabled) { 1239 return undefined; 1240 } 1241 1242 // We only ever reach this point if a request is for a URL which ought to 1243 // be shimmed. We never get here if a request is blocked, and we only 1244 // unblock requests if at least one shim matches it. 1245 1246 const { frameId, originUrl, requestId, tabId, type, url } = details; 1247 1248 // Ignore requests unrelated to tabs 1249 if (tabId < 0) { 1250 return undefined; 1251 } 1252 1253 // We need to base our checks not on the frame's host, but the tab's. 1254 const topHost = new URL((await browser.tabs.get(tabId)).url).hostname; 1255 const isPB = (await browser.tabs.get(details.tabId)).incognito; 1256 const unblocked = await browser.trackingProtection.wasRequestUnblocked( 1257 requestId, 1258 isPB 1259 ); 1260 1261 let match; 1262 let shimToApply; 1263 for (const shim of this.shims.values()) { 1264 await shim.ready; 1265 1266 if (!shim.enabled || (!shim.redirectsRequests && !shim.runFirst)) { 1267 continue; 1268 } 1269 1270 if (shim.onlyIfDFPIActive || shim.onlyIfPrivateBrowsing) { 1271 if (!isPB && shim.onlyIfPrivateBrowsing) { 1272 continue; 1273 } 1274 if ( 1275 shim.onlyIfDFPIActive && 1276 !(await browser.trackingProtection.isDFPIActive(isPB)) 1277 ) { 1278 continue; 1279 } 1280 } 1281 1282 // Do not apply the shim if it is only meant to apply when strict mode ETP 1283 // (content blocking) was going to block the request. 1284 if (!unblocked && shim.onlyIfBlockedByETP) { 1285 continue; 1286 } 1287 1288 if (!shim.meantForHost(topHost)) { 1289 continue; 1290 } 1291 1292 // If this URL and content type isn't meant for this shim, don't apply it. 1293 match = shim.isTriggeredByURLAndType(url, type); 1294 if (match) { 1295 if (!unblocked && match.onlyIfBlockedByETP) { 1296 continue; 1297 } 1298 1299 // If the user has already opted in for this shim, all requests it covers 1300 // should be allowed; no need for a shim anymore. 1301 if (shim.hasUserOptedInAlready(topHost, isPB)) { 1302 warn( 1303 `Allowing tracking ${type} ${url} on tab ${tabId} frame ${frameId} due to opt-in` 1304 ); 1305 shim.showOptInWarningOnce(tabId, new URL(originUrl).origin); 1306 return undefined; 1307 } 1308 shimToApply = shim; 1309 break; 1310 } 1311 } 1312 1313 let runFirst = false; 1314 1315 if (shimToApply) { 1316 // Note that sites may request the same shim twice, but because the requests 1317 // may differ enough for some to fail (CSP/CORS/etc), we always let the request 1318 // complete via local redirect. Shims should gracefully handle this as well. 1319 1320 const { target } = match; 1321 const { bug, file, id, name } = shimToApply; 1322 1323 // Determine whether we should inject helper scripts into the page. 1324 // webExposedShimHelpers is an optional list of helpers to provide 1325 // directly to the website (see script injection below). If not used shims 1326 // should pass an empty array to disable this functionality. 1327 const needsShimHelpers = 1328 shimToApply.webExposedShimHelpers || shimToApply.needsShimHelpers; 1329 1330 runFirst = shimToApply.runFirst; 1331 1332 const redirect = target || file; 1333 1334 warn( 1335 `Shimming tracking ${type} ${url} on tab ${tabId} frame ${frameId} with ${ 1336 redirect || runFirst 1337 }` 1338 ); 1339 1340 const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; 1341 1342 let needConsoleMessage = true; 1343 1344 if (shimToApply.isSmartblockEmbedShim) { 1345 try { 1346 await browser.tabs.executeScript(tabId, { 1347 file: `/lib/smartblock_embeds_helper.js`, 1348 frameId, 1349 runAt: "document_start", 1350 }); 1351 } catch (_) {} 1352 } 1353 1354 if (runFirst) { 1355 try { 1356 await browser.tabs.executeScript(tabId, { 1357 file: `/shims/${runFirst}`, 1358 frameId, 1359 runAt: "document_start", 1360 }); 1361 shimToApply.setActiveOnTab(tabId); 1362 } catch (_) {} 1363 } 1364 1365 // For scripts, we also set up any needed shim helpers. 1366 if (type === "script" && needsShimHelpers?.length) { 1367 try { 1368 await browser.tabs.executeScript(tabId, { 1369 file: "/lib/shim_messaging_helper.js", 1370 frameId, 1371 runAt: "document_start", 1372 }); 1373 const origin = new URL(originUrl).origin; 1374 await browser.tabs.sendMessage( 1375 tabId, 1376 { origin, shimId: id, needsShimHelpers, warning }, 1377 { frameId } 1378 ); 1379 needConsoleMessage = false; 1380 shimToApply.setActiveOnTab(tabId); 1381 } catch (_) {} 1382 } 1383 1384 if (needConsoleMessage) { 1385 try { 1386 await browser.tabs.executeScript(tabId, { 1387 code: `console.warn(${JSON.stringify(warning)})`, 1388 runAt: "document_start", 1389 }); 1390 } catch (_) {} 1391 } 1392 1393 if (!redirect.indexOf("http://") || !redirect.indexOf("https://")) { 1394 return { redirectUrl: redirect }; 1395 } 1396 1397 // If any shims matched the request to replace it, then redirect to the local 1398 // file bundled with SmartBlock, so the request never hits the network. 1399 return { redirectUrl: browser.runtime.getURL(`shims/${redirect}`) }; 1400 } 1401 1402 // Sanity check: if no shims end up handling this request, 1403 // yet it was meant to be blocked by ETP, then block it now. 1404 if (unblocked) { 1405 error(`unexpected: ${url} not shimmed on tab ${tabId} frame ${frameId}`); 1406 return { cancel: true }; 1407 } 1408 1409 if (!runFirst) { 1410 debug(`ignoring ${url} on tab ${tabId} frame ${frameId}`); 1411 } 1412 return undefined; 1413 } 1414 } 1415 1416 if (typeof module !== "undefined") { 1417 module.exports = Shims; 1418 }