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 7const EXPORTED_SYMBOLS = ["FirefoxMonitor"]; 8 9const { XPCOMUtils } = ChromeUtils.import( 10 "resource://gre/modules/XPCOMUtils.jsm" 11); 12 13XPCOMUtils.defineLazyModuleGetters(this, { 14 EveryWindow: "resource:///modules/EveryWindow.jsm", 15 PluralForm: "resource://gre/modules/PluralForm.jsm", 16 Preferences: "resource://gre/modules/Preferences.jsm", 17 RemoteSettings: "resource://services-settings/remote-settings.js", 18 Services: "resource://gre/modules/Services.jsm", 19}); 20 21const STYLESHEET = "chrome://browser/content/fxmonitor/FirefoxMonitor.css"; 22const ICON = "chrome://browser/content/fxmonitor/monitor32.svg"; 23 24this.FirefoxMonitor = { 25 // Map of breached site host -> breach metadata. 26 domainMap: new Map(), 27 28 // Reference to the extension object from the WebExtension context. 29 // Used for getting URIs for resources packaged in the extension. 30 extension: null, 31 32 // Whether we've started observing for the user visiting a breached site. 33 observerAdded: false, 34 35 // This is here for documentation, will be redefined to a lazy getter 36 // that creates and returns a string bundle in loadStrings(). 37 strings: null, 38 39 // This is here for documentation, will be redefined to a pref getter 40 // using XPCOMUtils.defineLazyPreferenceGetter in init(). 41 enabled: null, 42 43 kEnabledPref: "extensions.fxmonitor.enabled", 44 45 // This is here for documentation, will be redefined to a pref getter 46 // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit(). 47 // Telemetry event recording is enabled by default. 48 // If this pref exists and is true-y, it's disabled. 49 telemetryDisabled: null, 50 kTelemetryDisabledPref: "extensions.fxmonitor.telemetryDisabled", 51 52 kNotificationID: "fxmonitor", 53 54 // This is here for documentation, will be redefined to a pref getter 55 // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit(). 56 // The value of this property is used as the URL to which the user 57 // is directed when they click "Check Firefox Monitor". 58 FirefoxMonitorURL: null, 59 kFirefoxMonitorURLPref: "extensions.fxmonitor.FirefoxMonitorURL", 60 kDefaultFirefoxMonitorURL: "https://monitor.firefox.com", 61 62 // This is here for documentation, will be redefined to a pref getter 63 // using XPCOMUtils.defineLazyPreferenceGetter in delayedInit(). 64 // The pref stores whether the user has seen a breach alert already. 65 // The value is used in warnIfNeeded. 66 firstAlertShown: null, 67 kFirstAlertShownPref: "extensions.fxmonitor.firstAlertShown", 68 69 disable() { 70 Preferences.set(this.kEnabledPref, false); 71 }, 72 73 getString(aKey) { 74 return this.strings.GetStringFromName(aKey); 75 }, 76 77 getFormattedString(aKey, args) { 78 return this.strings.formatStringFromName(aKey, args); 79 }, 80 81 // We used to persist the list of hosts we've already warned the 82 // user for in this pref. Now, we check the pref at init and 83 // if it has a value, migrate the remembered hosts to content prefs 84 // and clear this one. 85 kWarnedHostsPref: "extensions.fxmonitor.warnedHosts", 86 migrateWarnedHostsIfNeeded() { 87 if (!Preferences.isSet(this.kWarnedHostsPref)) { 88 return; 89 } 90 91 let hosts = []; 92 try { 93 hosts = JSON.parse(Preferences.get(this.kWarnedHostsPref)); 94 } catch (ex) { 95 // Invalid JSON, nothing to be done. 96 } 97 98 let loadContext = Cu.createLoadContext(); 99 for (let host of hosts) { 100 this.rememberWarnedHost(loadContext, host); 101 } 102 103 Preferences.reset(this.kWarnedHostsPref); 104 }, 105 106 init() { 107 XPCOMUtils.defineLazyPreferenceGetter( 108 this, 109 "enabled", 110 this.kEnabledPref, 111 true, 112 (pref, oldVal, newVal) => { 113 if (newVal) { 114 this.startObserving(); 115 } else { 116 this.stopObserving(); 117 } 118 } 119 ); 120 121 if (this.enabled) { 122 this.startObserving(); 123 } 124 }, 125 126 // Used to enforce idempotency of delayedInit. delayedInit is 127 // called in startObserving() to ensure we load our strings, etc. 128 _delayedInited: false, 129 async delayedInit() { 130 if (this._delayedInited) { 131 return; 132 } 133 134 this._delayedInited = true; 135 136 XPCOMUtils.defineLazyServiceGetter( 137 this, 138 "_contentPrefService", 139 "@mozilla.org/content-pref/service;1", 140 "nsIContentPrefService2" 141 ); 142 143 this.migrateWarnedHostsIfNeeded(); 144 145 // Expire our telemetry on November 1, at which time 146 // we should redo data-review. 147 let telemetryExpiryDate = new Date(2019, 10, 1); // Month is zero-index 148 let today = new Date(); 149 let expired = today.getTime() > telemetryExpiryDate.getTime(); 150 151 Services.telemetry.registerEvents("fxmonitor", { 152 interaction: { 153 methods: ["interaction"], 154 objects: [ 155 "doorhanger_shown", 156 "doorhanger_removed", 157 "check_btn", 158 "dismiss_btn", 159 "never_show_btn", 160 ], 161 record_on_release: true, 162 expired, 163 }, 164 }); 165 166 let telemetryEnabled = !Preferences.get(this.kTelemetryDisabledPref); 167 Services.telemetry.setEventRecordingEnabled("fxmonitor", telemetryEnabled); 168 169 XPCOMUtils.defineLazyPreferenceGetter( 170 this, 171 "FirefoxMonitorURL", 172 this.kFirefoxMonitorURLPref, 173 this.kDefaultFirefoxMonitorURL 174 ); 175 176 XPCOMUtils.defineLazyPreferenceGetter( 177 this, 178 "firstAlertShown", 179 this.kFirstAlertShownPref, 180 false 181 ); 182 183 XPCOMUtils.defineLazyGetter(this, "strings", () => { 184 return Services.strings.createBundle( 185 "chrome://browser/locale/fxmonitor.properties" 186 ); 187 }); 188 189 XPCOMUtils.defineLazyPreferenceGetter( 190 this, 191 "telemetryDisabled", 192 this.kTelemetryDisabledPref, 193 false 194 ); 195 196 await this.loadBreaches(); 197 }, 198 199 kRemoteSettingsKey: "fxmonitor-breaches", 200 async loadBreaches() { 201 let populateSites = data => { 202 this.domainMap.clear(); 203 data.forEach(site => { 204 if ( 205 !site.Domain || 206 !site.Name || 207 !site.PwnCount || 208 !site.BreachDate || 209 !site.AddedDate 210 ) { 211 Cu.reportError( 212 `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify( 213 site 214 )}` 215 ); 216 return; 217 } 218 219 try { 220 this.domainMap.set(site.Domain, { 221 Name: site.Name, 222 PwnCount: site.PwnCount, 223 Year: new Date(site.BreachDate).getFullYear(), 224 AddedDate: site.AddedDate.split("T")[0], 225 }); 226 } catch (e) { 227 Cu.reportError( 228 `Firefox Monitor: malformed breach entry.\nSite:\n${JSON.stringify( 229 site 230 )}\nError:\n${e}` 231 ); 232 } 233 }); 234 }; 235 236 RemoteSettings(this.kRemoteSettingsKey).on("sync", event => { 237 let { 238 data: { current }, 239 } = event; 240 populateSites(current); 241 }); 242 243 let data = await RemoteSettings(this.kRemoteSettingsKey).get(); 244 if (data && data.length) { 245 populateSites(data); 246 } 247 }, 248 249 // nsIWebProgressListener implementation. 250 onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { 251 if ( 252 !(aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) || 253 !aWebProgress.isTopLevel || 254 aWebProgress.isLoadingDocument || 255 !Components.isSuccessCode(aStatus) 256 ) { 257 return; 258 } 259 260 let host; 261 try { 262 host = Services.eTLD.getBaseDomain(aRequest.URI); 263 } catch (e) { 264 // If we can't get the host for the URL, it's not one we 265 // care about for breach alerts anyway. 266 return; 267 } 268 269 this.warnIfNeeded(aBrowser, host); 270 }, 271 272 notificationsByWindow: new WeakMap(), 273 panelUIsByWindow: new WeakMap(), 274 275 async startObserving() { 276 if (this.observerAdded) { 277 return; 278 } 279 280 EveryWindow.registerCallback( 281 this.kNotificationID, 282 win => { 283 if (this.notificationsByWindow.has(win)) { 284 // We've already set up this window. 285 return; 286 } 287 288 this.notificationsByWindow.set(win, new Set()); 289 290 // Start listening across all tabs! The UI will 291 // be set up lazily when we actually need to show 292 // a notification. 293 this.delayedInit().then(() => { 294 win.gBrowser.addTabsProgressListener(this); 295 }); 296 }, 297 (win, closing) => { 298 // If the window is going away, don't bother doing anything. 299 if (closing) { 300 return; 301 } 302 303 let DOMWindowUtils = win.windowUtils; 304 DOMWindowUtils.removeSheetUsingURIString( 305 STYLESHEET, 306 DOMWindowUtils.AUTHOR_SHEET 307 ); 308 309 if (this.notificationsByWindow.has(win)) { 310 this.notificationsByWindow.get(win).forEach(n => { 311 n.remove(); 312 }); 313 this.notificationsByWindow.delete(win); 314 } 315 316 if (this.panelUIsByWindow.has(win)) { 317 let doc = win.document; 318 doc 319 .getElementById(`${this.kNotificationID}-notification-anchor`) 320 .remove(); 321 doc.getElementById(`${this.kNotificationID}-notification`).remove(); 322 this.panelUIsByWindow.delete(win); 323 } 324 325 win.gBrowser.removeTabsProgressListener(this); 326 } 327 ); 328 329 this.observerAdded = true; 330 }, 331 332 setupPanelUI(win) { 333 // Inject our stylesheet. 334 let DOMWindowUtils = win.windowUtils; 335 DOMWindowUtils.loadSheetUsingURIString( 336 STYLESHEET, 337 DOMWindowUtils.AUTHOR_SHEET 338 ); 339 340 // Setup the popup notification stuff. First, the URL bar icon: 341 let doc = win.document; 342 let notificationBox = doc.getElementById("notification-popup-box"); 343 // We create a box to use as the anchor, and put an icon image 344 // inside it. This way, when we animate the icon, its scale change 345 // does not cause the popup notification to bounce due to the anchor 346 // point moving. 347 let anchorBox = doc.createXULElement("box"); 348 anchorBox.setAttribute("id", `${this.kNotificationID}-notification-anchor`); 349 anchorBox.classList.add("notification-anchor-icon"); 350 let img = doc.createXULElement("image"); 351 img.setAttribute("role", "button"); 352 img.classList.add(`${this.kNotificationID}-icon`); 353 img.style.listStyleImage = `url(${ICON})`; 354 anchorBox.appendChild(img); 355 notificationBox.appendChild(anchorBox); 356 img.setAttribute( 357 "tooltiptext", 358 this.getFormattedString("fxmonitor.anchorIcon.tooltiptext", [ 359 this.getString("fxmonitor.brandName"), 360 ]) 361 ); 362 363 // Now, the popupnotificationcontent: 364 let parentElt = doc.defaultView.PopupNotifications.panel.parentNode; 365 let pn = doc.createXULElement("popupnotification"); 366 let pnContent = doc.createXULElement("popupnotificationcontent"); 367 let panelUI = new PanelUI(doc); 368 pnContent.appendChild(panelUI.box); 369 pn.appendChild(pnContent); 370 pn.setAttribute("id", `${this.kNotificationID}-notification`); 371 pn.setAttribute("hidden", "true"); 372 parentElt.appendChild(pn); 373 this.panelUIsByWindow.set(win, panelUI); 374 return panelUI; 375 }, 376 377 stopObserving() { 378 if (!this.observerAdded) { 379 return; 380 } 381 382 EveryWindow.unregisterCallback(this.kNotificationID); 383 384 this.observerAdded = false; 385 }, 386 387 async hostAlreadyWarned(loadContext, host) { 388 return new Promise((resolve, reject) => { 389 this._contentPrefService.getByDomainAndName( 390 host, 391 "extensions.fxmonitor.hostAlreadyWarned", 392 loadContext, 393 { 394 handleCompletion: () => resolve(false), 395 handleResult: result => resolve(result.value), 396 } 397 ); 398 }); 399 }, 400 401 rememberWarnedHost(loadContext, host) { 402 this._contentPrefService.set( 403 host, 404 "extensions.fxmonitor.hostAlreadyWarned", 405 true, 406 loadContext 407 ); 408 }, 409 410 async warnIfNeeded(browser, host) { 411 if ( 412 !this.enabled || 413 !this.domainMap.has(host) || 414 (await this.hostAlreadyWarned(browser.loadContext, host)) 415 ) { 416 return; 417 } 418 419 let site = this.domainMap.get(host); 420 421 // We only alert for breaches that were found up to 2 months ago, 422 // except for the very first alert we show the user - in which case, 423 // we include breaches found in the last three years. 424 let breachDateThreshold = new Date(); 425 if (this.firstAlertShown) { 426 breachDateThreshold.setMonth(breachDateThreshold.getMonth() - 2); 427 } else { 428 breachDateThreshold.setFullYear(breachDateThreshold.getFullYear() - 1); 429 } 430 431 if (new Date(site.AddedDate).getTime() < breachDateThreshold.getTime()) { 432 return; 433 } else if (!this.firstAlertShown) { 434 Preferences.set(this.kFirstAlertShownPref, true); 435 } 436 437 this.rememberWarnedHost(browser.loadContext, host); 438 439 let doc = browser.ownerDocument; 440 let win = doc.defaultView; 441 let panelUI = this.panelUIsByWindow.get(win); 442 if (!panelUI) { 443 panelUI = this.setupPanelUI(win); 444 } 445 446 let animatedOnce = false; 447 let populatePanel = event => { 448 switch (event) { 449 case "showing": 450 panelUI.refresh(site); 451 if (animatedOnce) { 452 // If we've already animated once for this site, don't animate again. 453 doc 454 .getElementById("notification-popup") 455 .setAttribute("fxmonitoranimationdone", "true"); 456 doc 457 .getElementById(`${this.kNotificationID}-notification-anchor`) 458 .setAttribute("fxmonitoranimationdone", "true"); 459 break; 460 } 461 // Make sure we animate if we're coming from another tab that has 462 // this attribute set. 463 doc 464 .getElementById("notification-popup") 465 .removeAttribute("fxmonitoranimationdone"); 466 doc 467 .getElementById(`${this.kNotificationID}-notification-anchor`) 468 .removeAttribute("fxmonitoranimationdone"); 469 break; 470 case "shown": 471 animatedOnce = true; 472 break; 473 case "removed": 474 this.notificationsByWindow 475 .get(win) 476 .delete( 477 win.PopupNotifications.getNotification( 478 this.kNotificationID, 479 browser 480 ) 481 ); 482 this.recordEvent("doorhanger_removed"); 483 break; 484 } 485 }; 486 487 let n = win.PopupNotifications.show( 488 browser, 489 this.kNotificationID, 490 "", 491 `${this.kNotificationID}-notification-anchor`, 492 panelUI.primaryAction, 493 panelUI.secondaryActions, 494 { 495 persistent: true, 496 hideClose: true, 497 eventCallback: populatePanel, 498 popupIconURL: ICON, 499 } 500 ); 501 502 this.recordEvent("doorhanger_shown"); 503 504 this.notificationsByWindow.get(win).add(n); 505 }, 506 507 recordEvent(aEventName) { 508 if (this.telemetryDisabled) { 509 return; 510 } 511 512 Services.telemetry.recordEvent("fxmonitor", "interaction", aEventName); 513 }, 514}; 515 516function PanelUI(doc) { 517 this.site = null; 518 this.doc = doc; 519 520 let box = doc.createXULElement("vbox"); 521 522 let elt = doc.createXULElement("description"); 523 elt.textContent = this.getString("fxmonitor.popupHeader"); 524 elt.classList.add("headerText"); 525 box.appendChild(elt); 526 527 elt = doc.createXULElement("description"); 528 elt.classList.add("popupText"); 529 box.appendChild(elt); 530 531 this.box = box; 532} 533 534PanelUI.prototype = { 535 getString(aKey) { 536 return FirefoxMonitor.getString(aKey); 537 }, 538 539 getFormattedString(aKey, args) { 540 return FirefoxMonitor.getFormattedString(aKey, args); 541 }, 542 543 get brandString() { 544 if (this._brandString) { 545 return this._brandString; 546 } 547 return (this._brandString = this.getString("fxmonitor.brandName")); 548 }, 549 550 getFirefoxMonitorURL: aSiteName => { 551 return `${FirefoxMonitor.FirefoxMonitorURL}/?breach=${encodeURIComponent( 552 aSiteName 553 )}&utm_source=firefox&utm_medium=popup`; 554 }, 555 556 get primaryAction() { 557 if (this._primaryAction) { 558 return this._primaryAction; 559 } 560 return (this._primaryAction = { 561 label: this.getFormattedString("fxmonitor.checkButton.label", [ 562 this.brandString, 563 ]), 564 accessKey: this.getString("fxmonitor.checkButton.accessKey"), 565 callback: () => { 566 let win = this.doc.defaultView; 567 win.openTrustedLinkIn( 568 this.getFirefoxMonitorURL(this.site.Name), 569 "tab", 570 {} 571 ); 572 573 FirefoxMonitor.recordEvent("check_btn"); 574 }, 575 }); 576 }, 577 578 get secondaryActions() { 579 if (this._secondaryActions) { 580 return this._secondaryActions; 581 } 582 return (this._secondaryActions = [ 583 { 584 label: this.getString("fxmonitor.dismissButton.label"), 585 accessKey: this.getString("fxmonitor.dismissButton.accessKey"), 586 callback: () => { 587 FirefoxMonitor.recordEvent("dismiss_btn"); 588 }, 589 }, 590 { 591 label: this.getFormattedString("fxmonitor.neverShowButton.label", [ 592 this.brandString, 593 ]), 594 accessKey: this.getString("fxmonitor.neverShowButton.accessKey"), 595 callback: () => { 596 FirefoxMonitor.disable(); 597 FirefoxMonitor.recordEvent("never_show_btn"); 598 }, 599 }, 600 ]); 601 }, 602 603 refresh(site) { 604 this.site = site; 605 606 let elt = this.box.querySelector(".popupText"); 607 608 // If > 100k, the PwnCount is rounded down to the most significant 609 // digit and prefixed with "More than". 610 // Ex.: 12,345 -> 12,345 611 // 234,567 -> More than 200,000 612 // 345,678,901 -> More than 300,000,000 613 // 4,567,890,123 -> More than 4,000,000,000 614 let k100k = 100000; 615 let pwnCount = site.PwnCount; 616 let stringName = "fxmonitor.popupText"; 617 if (pwnCount > k100k) { 618 let multiplier = 1; 619 while (pwnCount >= 10) { 620 pwnCount /= 10; 621 multiplier *= 10; 622 } 623 pwnCount = Math.floor(pwnCount) * multiplier; 624 stringName = "fxmonitor.popupTextRounded"; 625 } 626 627 elt.textContent = PluralForm.get(pwnCount, this.getString(stringName)) 628 .replace("#1", pwnCount.toLocaleString()) 629 .replace("#2", site.Name) 630 .replace("#3", site.Year) 631 .replace("#4", this.brandString); 632 }, 633}; 634