Bug 1529643 - Implement MitM priming on certificate error pages. r=keeler,mconley
☠☠ backed out by 99581ff1fb9d ☠ ☠
authorJohann Hofmann <jhofmann@mozilla.com>
Wed, 13 Mar 2019 21:04:02 +0000
changeset 521782 e4718a35d70b174f7acf7528c3de81b567a87b66
parent 521781 a8f1fd35587fa7df233e2fced117f22926b7cabd
child 521783 000dfd4caca0183893f821c4856d2a97c043bf5c
push id10867
push userdvarga@mozilla.com
push dateThu, 14 Mar 2019 15:20:45 +0000
treeherdermozilla-beta@abad13547875 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskeeler, mconley
bugs1529643
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1529643 - Implement MitM priming on certificate error pages. r=keeler,mconley Differential Revision: https://phabricator.services.mozilla.com/D22406
browser/actors/NetErrorChild.jsm
browser/app/profile/firefox.js
browser/base/content/browser.js
browser/base/content/test/about/browser.ini
browser/base/content/test/about/browser_aboutCertError_mitm.js
testing/modules/TestUtils.jsm
toolkit/modules/BrowserUtils.jsm
--- a/browser/actors/NetErrorChild.jsm
+++ b/browser/actors/NetErrorChild.jsm
@@ -23,16 +23,18 @@ XPCOMUtils.defineLazyGetter(this, "gPipN
 });
 XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
   return Services.strings.createBundle("chrome://branding/locale/brand.properties");
 });
 XPCOMUtils.defineLazyPreferenceGetter(this, "newErrorPagesEnabled",
   "browser.security.newcerterrorpage.enabled");
 XPCOMUtils.defineLazyPreferenceGetter(this, "mitmErrorPageEnabled",
   "browser.security.newcerterrorpage.mitm.enabled");
+XPCOMUtils.defineLazyPreferenceGetter(this, "mitmPrimingEnabled",
+  "security.certerrors.mitm.priming.enabled");
 XPCOMUtils.defineLazyGetter(this, "gNSSErrorsBundle", function() {
   return Services.strings.createBundle("chrome://pipnss/locale/nsserrors.properties");
 });
 
 
 const SEC_ERROR_BASE          = Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE;
 const MOZILLA_PKIX_ERROR_BASE = Ci.nsINSSErrorsService.MOZILLA_PKIX_ERROR_BASE;
 
@@ -373,16 +375,26 @@ class NetErrorChild extends ActorChild {
       } else {
         let offset = (doc.documentElement.clientHeight / 2) - (textContainer.clientHeight / 2);
         if (offset > 0) {
           textContainer.style.marginTop = `${offset}px`;
         }
       }
     }
 
+    // Check if the connection is being man-in-the-middled. When the parent
+    // detects an intercepted connection, the page may be reloaded with a new
+    // error code (MOZILLA_PKIX_ERROR_MITM_DETECTED).
+    if (newErrorPagesEnabled && mitmPrimingEnabled &&
+        msg.data.code == SEC_ERROR_UNKNOWN_ISSUER &&
+        // Only do this check for top-level failures.
+        doc.ownerGlobal.top === doc.ownerGlobal) {
+      this.mm.sendAsyncMessage("Browser:PrimeMitm");
+    }
+
     let div = doc.getElementById("certificateErrorText");
     div.textContent = msg.data.info;
     this._setTechDetails(msg, doc);
     let learnMoreLink = doc.getElementById("learnMoreLink");
     let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
     learnMoreLink.setAttribute("href", baseURL + "connection-not-secure");
     let errWhatToDo = doc.getElementById("es_nssBadCert_" + msg.data.codeString);
     let es = doc.getElementById("errorWhatToDoText");
@@ -448,16 +460,24 @@ class NetErrorChild extends ActorChild {
         descriptionContainer.append(adminDescription);
 
         learnMoreLink.href = baseURL + "symantec-warning";
 
         updateContainerPosition();
         break;
       case MOZILLA_PKIX_ERROR_MITM_DETECTED:
         if (newErrorPagesEnabled && mitmErrorPageEnabled) {
+          let autoEnabledEnterpriseRoots =
+            Services.prefs.getBoolPref("security.enterprise_roots.auto-enabled", false);
+          if (mitmPrimingEnabled && autoEnabledEnterpriseRoots) {
+            // If we automatically tried to import enterprise root certs but it didn't
+            // fix the MITM, reset the pref.
+            this.mm.sendAsyncMessage("Browser:ResetEnterpriseRootsPref");
+          }
+
           // We don't actually know what the MitM is called (since we don't
           // maintain a list), so we'll try and display the common name of the
           // root issuer to the user. In the worst case they are as clueless as
           // before, in the best case this gives them an actionable hint.
           // This may be revised in the future.
           let {securityInfo} = docShell.failedChannel;
           securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
           let mitmName = null;
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -966,16 +966,19 @@ pref("app.productInfo.baseURL", "https:/
 pref("security.alternate_certificate_error_page", "certerror");
 
 // Enable the new certificate error pages.
 pref("browser.security.newcerterrorpage.enabled", true);
 
 pref("browser.security.newcerterrorpage.mitm.enabled", true);
 pref("security.certerrors.recordEventTelemetry", true);
 pref("security.certerrors.permanentOverride", true);
+pref("security.certerrors.mitm.priming.enabled", true);
+pref("security.certerrors.mitm.priming.endpoint", "https://mitmdetection.services.mozilla.com/");
+pref("security.certerrors.mitm.auto_enable_enterprise_roots", false);
 
 // Whether to start the private browsing mode at application startup
 pref("browser.privatebrowsing.autostart", false);
 
 // Whether to show the new private browsing UI with in-content search box.
 pref("browser.privatebrowsing.searchUI", true);
 
 // Whether the bookmark panel should be shown when bookmarking a page.
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -2930,16 +2930,19 @@ function PageProxyClickHandler(aEvent) {
   if (aEvent.button == 1 && Services.prefs.getBoolPref("middlemouse.paste"))
     middleMousePaste(aEvent);
 }
 
 // Values for telemtery bins: see TLS_ERROR_REPORT_UI in Histograms.json
 const TLS_ERROR_REPORT_TELEMETRY_AUTO_CHECKED   = 2;
 const TLS_ERROR_REPORT_TELEMETRY_AUTO_UNCHECKED = 3;
 
+const SEC_ERROR_BASE = Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE;
+const SEC_ERROR_UNKNOWN_ISSUER = SEC_ERROR_BASE + 13;
+
 const PREF_SSL_IMPACT_ROOTS = ["security.tls.version.", "security.ssl3."];
 
 /**
  * Handle command events bubbling up from error page content
  * or from about:newtab or from remote error pages that invoke
  * us via async messaging.
  */
 var BrowserOnClick = {
@@ -2948,30 +2951,34 @@ var BrowserOnClick = {
     mm.addMessageListener("Browser:CertExceptionError", this);
     mm.addMessageListener("Browser:OpenCaptivePortalPage", this);
     mm.addMessageListener("Browser:SiteBlockedError", this);
     mm.addMessageListener("Browser:EnableOnlineMode", this);
     mm.addMessageListener("Browser:SetSSLErrorReportAuto", this);
     mm.addMessageListener("Browser:ResetSSLPreferences", this);
     mm.addMessageListener("Browser:SSLErrorReportTelemetry", this);
     mm.addMessageListener("Browser:SSLErrorGoBack", this);
+    mm.addMessageListener("Browser:PrimeMitm", this);
+    mm.addMessageListener("Browser:ResetEnterpriseRootsPref", this);
 
     Services.obs.addObserver(this, "captive-portal-login-abort");
     Services.obs.addObserver(this, "captive-portal-login-success");
   },
 
   uninit() {
     let mm = window.messageManager;
     mm.removeMessageListener("Browser:CertExceptionError", this);
     mm.removeMessageListener("Browser:SiteBlockedError", this);
     mm.removeMessageListener("Browser:EnableOnlineMode", this);
     mm.removeMessageListener("Browser:SetSSLErrorReportAuto", this);
     mm.removeMessageListener("Browser:ResetSSLPreferences", this);
     mm.removeMessageListener("Browser:SSLErrorReportTelemetry", this);
     mm.removeMessageListener("Browser:SSLErrorGoBack", this);
+    mm.removeMessageListener("Browser:PrimeMitm", this);
+    mm.removeMessageListener("Browser:ResetEnterpriseRootsPref", this);
 
     Services.obs.removeObserver(this, "captive-portal-login-abort");
     Services.obs.removeObserver(this, "captive-portal-login-success");
   },
 
   observe(aSubject, aTopic, aData) {
     switch (aTopic) {
       case "captive-portal-login-abort":
@@ -3026,17 +3033,84 @@ var BrowserOnClick = {
       case "Browser:SSLErrorReportTelemetry":
         let reportStatus = msg.data.reportStatus;
         Services.telemetry.getHistogramById("TLS_ERROR_REPORT_UI")
           .add(reportStatus);
       break;
       case "Browser:SSLErrorGoBack":
         goBackFromErrorPage();
       break;
-    }
+      case "Browser:PrimeMitm":
+        this.primeMitm(msg.target);
+      break;
+      case "Browser:ResetEnterpriseRootsPref":
+        Services.prefs.clearUserPref("security.enterprise_roots.enabled");
+        Services.prefs.clearUserPref("security.enterprise_roots.auto-enabled");
+      break;
+    }
+  },
+
+  /**
+   * This function does a canary request to a reliable, maintained endpoint, in
+   * order to help network code detect a system-wide man-in-the-middle.
+   */
+  primeMitm(browser) {
+    // If we already have a mitm canary issuer stored, then don't bother with the
+    // extra request. This will be cleared on every update ping.
+    if (Services.prefs.getStringPref("security.pki.mitm_canary_issuer", null)) {
+      return;
+    }
+
+    let url = Services.prefs.getStringPref("security.certerrors.mitm.priming.endpoint");
+    let request = new XMLHttpRequest({mozAnon: true});
+    request.open("HEAD", url);
+    request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+    request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+
+    request.addEventListener("error", event => {
+      // Make sure the user is still on the cert error page.
+      if (!browser.documentURI.spec.startsWith("about:certerror")) {
+        return;
+      }
+
+      let secInfo = request.channel.securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+      if (secInfo.errorCode != SEC_ERROR_UNKNOWN_ISSUER) {
+        return;
+      }
+
+      // When we get to this point there's already something deeply wrong, it's very likely
+      // that there is indeed a system-wide MitM.
+      if (secInfo.serverCert && secInfo.serverCert.issuerName) {
+        // Grab the issuer of the certificate used in the exchange and store it so that our
+        // network-level MitM detection code has a comparison baseline.
+        Services.prefs.setStringPref("security.pki.mitm_canary_issuer", secInfo.serverCert.issuerName);
+
+        // MitM issues are sometimes caused by software not registering their root certs in the
+        // Firefox root store. We might opt for using third party roots from the system root store.
+        if (Services.prefs.getBoolPref("security.certerrors.mitm.auto_enable_enterprise_roots")) {
+          if (!Services.prefs.getBoolPref("security.enterprise_roots.enabled")) {
+            // Loading enterprise roots happens on a background thread, so wait for import to finish.
+            BrowserUtils.promiseObserved("psm:enterprise-certs-imported").then(() => {
+              if (browser.documentURI.spec.startsWith("about:certerror")) {
+                browser.reload();
+              }
+            });
+
+            Services.prefs.setBoolPref("security.enterprise_roots.enabled", true);
+            // Record that this pref was automatically set.
+            Services.prefs.setBoolPref("security.enterprise_roots.auto-enabled", true);
+          }
+        } else {
+          // Need to reload the page to make sure network code picks up the canary issuer pref.
+          browser.reload();
+        }
+      }
+    });
+
+    request.send(null);
   },
 
   onCertError(browser, elementId, isTopFrame, location, securityInfoAsString, frameId) {
     let securityInfo;
     let cert;
 
     switch (elementId) {
       case "viewCertificate":
--- a/browser/base/content/test/about/browser.ini
+++ b/browser/base/content/test/about/browser.ini
@@ -7,16 +7,17 @@ support-files =
   POSTSearchEngine.xml
   dummy_page.html
 prefs =
   browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar=false
 
 [browser_aboutCertError.js]
 [browser_aboutCertError_clockSkew.js]
 [browser_aboutCertError_exception.js]
+[browser_aboutCertError_mitm.js]
 [browser_aboutCertError_telemetry.js]
 [browser_aboutHome_search_POST.js]
 [browser_aboutHome_search_composing.js]
 [browser_aboutHome_search_searchbar.js]
 [browser_aboutHome_search_suggestion.js]
 skip-if = os == "mac" || (os == "linux" && (!debug || bits == 64)) || (os == 'win' && os_version == '10.0' && bits == 64 && !debug) # Bug 1399648, bug 1402502
 [browser_aboutHome_search_telemetry.js]
 [browser_aboutNetError.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_mitm.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PREF_NEW_CERT_ERRORS = "browser.security.newcerterrorpage.enabled";
+const PREF_MITM_PRIMING = "security.certerrors.mitm.priming.enabled";
+const PREF_MITM_PRIMING_ENDPOINT = "security.certerrors.mitm.priming.endpoint";
+const PREF_MITM_CANARY_ISSUER = "security.pki.mitm_canary_issuer";
+const PREF_MITM_AUTO_ENABLE_ENTERPRISE_ROOTS = "security.certerrors.mitm.auto_enable_enterprise_roots";
+const PREF_ENTERPRISE_ROOTS = "security.enterprise_roots.enabled";
+
+const UNKNOWN_ISSUER = "https://untrusted.example.com";
+
+// Check that basic MitM priming works and the MitM error page is displayed successfully.
+add_task(async function checkMitmPriming() {
+  await SpecialPowers.pushPrefEnv({"set": [
+    [PREF_NEW_CERT_ERRORS, true],
+    [PREF_MITM_PRIMING, true],
+    [PREF_MITM_PRIMING_ENDPOINT, UNKNOWN_ISSUER],
+  ]});
+
+  let browser;
+  let certErrorLoaded;
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
+    gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, UNKNOWN_ISSUER);
+    browser = gBrowser.selectedBrowser;
+    certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+  }, false);
+
+  await certErrorLoaded;
+
+  // The page will reload after the initial canary request, so we'll just
+  // wait until we're seeing the dedicated MitM page.
+  await TestUtils.waitForCondition(function() {
+    return ContentTask.spawn(browser, {}, () => {
+      return content.document.body.getAttribute("code") == "MOZILLA_PKIX_ERROR_MITM_DETECTED";
+    });
+  }, "Loads the MitM error page.");
+
+  ok(true, "Successfully loaded the MitM error page.");
+
+  is(Services.prefs.getStringPref(PREF_MITM_CANARY_ISSUER), "CN=Unknown CA", "Stored the correct issuer");
+
+  await ContentTask.spawn(browser, {}, () => {
+    let mitmName1 = content.document.querySelector("#errorShortDescText .mitm-name");
+    ok(ContentTaskUtils.is_visible(mitmName1), "Potential man in the middle is displayed");
+    is(mitmName1.textContent, "Unknown CA", "Shows the name of the issuer.");
+
+    let mitmName2 = content.document.querySelector("#errorWhatToDoText .mitm-name");
+    ok(ContentTaskUtils.is_visible(mitmName2), "Potential man in the middle is displayed");
+    is(mitmName2.textContent, "Unknown CA", "Shows the name of the issuer.");
+  });
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  Services.prefs.clearUserPref(PREF_MITM_CANARY_ISSUER);
+});
+
+// Check that we set the enterprise roots pref correctly on MitM
+add_task(async function checkMitmAutoEnableEnterpriseRoots() {
+  await SpecialPowers.pushPrefEnv({"set": [
+    [PREF_NEW_CERT_ERRORS, true],
+    [PREF_MITM_PRIMING, true],
+    [PREF_MITM_PRIMING_ENDPOINT, UNKNOWN_ISSUER],
+    [PREF_MITM_AUTO_ENABLE_ENTERPRISE_ROOTS, true],
+  ]});
+
+  let browser;
+  let certErrorLoaded;
+
+  let prefChanged = TestUtils.waitForPrefChange(PREF_ENTERPRISE_ROOTS, value => value === true);
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
+    gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, UNKNOWN_ISSUER);
+    browser = gBrowser.selectedBrowser;
+    certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+  }, false);
+
+  await certErrorLoaded;
+  await prefChanged;
+
+  // The page will reload after the initial canary request, so we'll just
+  // wait until we're seeing the dedicated MitM page.
+  await TestUtils.waitForCondition(function() {
+    return ContentTask.spawn(browser, {}, () => {
+      return content.document.body.getAttribute("code") == "MOZILLA_PKIX_ERROR_MITM_DETECTED";
+    });
+  }, "Loads the MitM error page.");
+
+  ok(true, "Successfully loaded the MitM error page.");
+
+  ok(!Services.prefs.prefHasUserValue(PREF_ENTERPRISE_ROOTS), "Flipped the enterprise roots pref back");
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  Services.prefs.clearUserPref(PREF_MITM_CANARY_ISSUER);
+});
--- a/testing/modules/TestUtils.jsm
+++ b/testing/modules/TestUtils.jsm
@@ -65,16 +65,63 @@ var TestUtils = {
           Services.obs.removeObserver(observer, topic);
           reject(ex);
         }
       }, topic);
     });
   },
 
   /**
+   * Waits for the specified preference to be change.
+   *
+   * @param {string} prefName
+   *        The pref to observe.
+   * @param {function} checkFn [optional]
+   *        Called with the new preference value as argument, should return true if the
+   *        notification is the expected one, or false if it should be ignored
+   *        and listening should continue. If not specified, the first
+   *        notification for the specified topic resolves the returned promise.
+   *
+   * @note Because this function is intended for testing, any error in checkFn
+   *       will cause the returned promise to be rejected instead of waiting for
+   *       the next notification, since this is probably a bug in the test.
+   *
+   * @return {Promise}
+   * @resolves The value of the preference.
+   */
+  waitForPrefChange(prefName, checkFn) {
+    return new Promise((resolve, reject) => {
+      Services.prefs.addObserver(prefName, function observer(subject, topic, data) {
+        try {
+          let prefValue = null;
+          switch (Services.prefs.getPrefType(prefName)) {
+          case Services.prefs.PREF_STRING:
+            prefValue = Services.prefs.getStringPref(prefName);
+            break;
+          case Services.prefs.PREF_INT:
+            prefValue = Services.prefs.getIntPref(prefName);
+            break;
+          case Services.prefs.PREF_BOOL:
+            prefValue = Services.prefs.getBoolPref(prefName);
+            break;
+          }
+          if (checkFn && !checkFn(prefValue)) {
+            return;
+          }
+          Services.prefs.removeObserver(prefName, observer);
+          resolve(prefValue);
+        } catch (ex) {
+          Services.prefs.removeObserver(prefName, observer);
+          reject(ex);
+        }
+      });
+    });
+  },
+
+  /**
    * Takes a screenshot of an area and returns it as a data URL.
    *
    * @param eltOrRect
    *        The DOM node or rect ({left, top, width, height}) to screenshot.
    * @param win
    *        The current window.
    */
   screenshotArea(eltOrRect, win) {
--- a/toolkit/modules/BrowserUtils.jsm
+++ b/toolkit/modules/BrowserUtils.jsm
@@ -682,9 +682,33 @@ var BrowserUtils = {
           fragment.appendChild(doc.createTextNode(part));
         }
       } else {
         fragment.appendChild(part);
       }
     }
     return fragment;
   },
+
+  /**
+   * Returns a Promise which resolves when the given observer topic has been
+   * observed.
+   *
+   * @param {string} topic
+   *        The topic to observe.
+   * @param {function(nsISupports, string)} [test]
+   *        An optional test function which, when called with the
+   *        observer's subject and data, should return true if this is the
+   *        expected notification, false otherwise.
+   * @returns {Promise<object>}
+   */
+  promiseObserved(topic, test = () => true) {
+    return new Promise(resolve => {
+      let observer = (subject, topic, data) => {
+        if (test(subject, data)) {
+          Services.obs.removeObserver(observer, topic);
+          resolve({subject, data});
+        }
+      };
+      Services.obs.addObserver(observer, topic);
+    });
+  },
 };