Bug 1103196 - Add ability to ignore invalid TLS certificates; r?automatedtester,keeler,mossop draft
authorAndreas Tolfsen <ato@mozilla.com>
Sun, 06 Nov 2016 18:03:31 +0000
changeset 439891 cb8d0d1e5fedb4f20ab69313cc7c9c4a12b24556
parent 439890 007b922c4edce2b4181f0f23363c3c3a6803076f
child 537270 58432591c114175ba9ca08b4c845a9624b60e5b0
push id36114
push userbmo:ato@mozilla.com
push dateWed, 16 Nov 2016 18:21:30 +0000
reviewersautomatedtester, keeler, mossop
bugs1103196
milestone53.0a1
Bug 1103196 - Add ability to ignore invalid TLS certificates; r?automatedtester,keeler,mossop When the `acceptInsecureCerts` capability is set to true on creating a new Marionette session, a `nsICertOverrideService` override service is installed that causes all invalid TLS certificates to be ignored. This is in line with the expectations of the WebDriver specification. It is worth noting that this is a potential security risk and that this feature is only available in Gecko when the Marionette server is enabled. MozReview-Commit-ID: BXrQw17TgDy
testing/marionette/cert.js
testing/marionette/driver.js
testing/marionette/harness/marionette/tests/unit/test_navigation.py
testing/marionette/jar.mn
new file mode 100644
--- /dev/null
+++ b/testing/marionette/cert.js
@@ -0,0 +1,141 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["cert"];
+
+const registrar =
+    Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+const CONTRACT_ID = "@mozilla.org/security/certoverride;1";
+const TIME_OFFSET_PREF = "test.currentTimeOffsetSeconds";
+const CERT_PINNING_ENFORCEMENT_PREF =
+    "security.cert_pinning.enforcement_level";
+
+/** TLS certificate service override management for Marionette. */
+this.cert = {
+  Error: {
+    Untrusted: 1,
+    Mismatch: 2,
+    Time: 4,
+  },
+
+  currentOverride: null,
+};
+
+/**
+ * Installs a TLS certificate service override.
+ *
+ * The provided |service| must implement the |register| and |unregister|
+ * functions that causes a new |nsICertOverrideService| interface
+ * implementation to be registered with the |nsIComponentRegistrar|.
+ *
+ * After |service| is registered and made the |cert.currentOverride|,
+ * |nsICertOverrideService| is reinitialised to cause all Gecko components
+ * to pick up the new service.
+ *
+ * If an override is already installed, i.e. when |cert.currentOverride|
+ * is not null, this functions acts as a NOOP.
+ *
+ * The |nsICertOverrideService| is reinitialised after the service
+ * provided by |generator| has been installed.
+ *
+ * @param {cert.Override} service
+ *     Service generator that registers and unregisters the XPCOM service.
+ *
+ * @throws {Components.Exception}
+ *     If unable to register or initialise |service|.
+ */
+cert.installOverride = function(service) {
+  if (this.currentOverride) {
+    return;
+  }
+
+  service.register();
+  cert.currentOverride = service;
+
+  // reinitialise service
+  Cc["@mozilla.org/security/certoverride;1"]
+      .getService(Ci.nsICertOverrideService);
+};
+
+/**
+ * Uninstall a TLS certificate service override.
+ *
+ * After the service has been unregistered, |cert.currentOverride|
+ * is reset to null.
+ *
+ * If there no current override installed, i.e. if |cert.currentOverride|
+ * is null, this function acts as a NOOP.
+ */
+cert.uninstallOverride = function() {
+  if (!cert.currentOverride) {
+    return;
+  }
+  cert.currentOverride.unregister();
+  this.currentOverride = null;
+};
+
+/**
+ * Certificate override service that acts in an all-inclusive manner
+ * on TLS certificates.
+ *
+ * When an invalid certificate is encountered, it is overriden
+ * with the |matching| bit level, which is typically a combination of
+ * |cert.Error.Untrusted|, |cert.Error.Mismatch|, and |cert.Error.Time|.
+ *
+ * @type cert.Override
+ *
+ * @param {cert.Error} matching
+ *     Precise set of certificate errors to override.
+ *
+ * @throws {Components.Exception}
+ *     If there are any problems registering the service.
+ */
+cert.SweepingOverride = function(matching) {
+  const CID = Components.ID("{4b67cce0-a51c-11e6-9598-0800200c9a66}");
+  const DESC = "All-encompassing cert service that matches on a bitflag";
+
+  // This needs to be an old-style class with a function constructor
+  // and prototype assignment because... XPCOM.  Any attempt at
+  // modernisation will be met with cryptic error messages which will
+  // make your life miserable.
+  let service = function() {};
+  service.prototype = {
+    hasMatchingOverride: function(
+        aHostName, aPort, aCert, aOverrideBits, aIsTemporary) {
+      aIsTemporary.value = false;
+      aOverrideBits.value = matching;
+      return true;
+    },
+
+    QueryInterface: XPCOMUtils.generateQI([Ci.nsICertOverrideService]),
+  };
+  let factory = XPCOMUtils.generateSingletonFactory(service);
+
+  return {
+    register: function() {
+      // make it possible to register certificate overrides for domains
+      // that use HSTS or HPKP
+      let offsetSeconds = 19 * 7 * 24 * 60 * 60;
+      Preferences.set(TIME_OFFSET_PREF, offsetSeconds);
+      Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0);
+
+      registrar.registerFactory(CID, DESC, CONTRACT_ID, factory);
+    },
+
+    unregister: function() {
+      registrar.unregisterFactory(CID, factory);
+
+      Preferences.reset(TIME_OFFSET_PREF);
+      Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF);
+    },
+  };
+};
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -16,16 +16,17 @@ Cu.import("resource://gre/modules/XPCOMU
 
 XPCOMUtils.defineLazyServiceGetter(
     this, "cookieManager", "@mozilla.org/cookiemanager;1", "nsICookieManager2");
 
 Cu.import("chrome://marionette/content/accessibility.js");
 Cu.import("chrome://marionette/content/assert.js");
 Cu.import("chrome://marionette/content/atom.js");
 Cu.import("chrome://marionette/content/browser.js");
+Cu.import("chrome://marionette/content/cert.js");
 Cu.import("chrome://marionette/content/element.js");
 Cu.import("chrome://marionette/content/error.js");
 Cu.import("chrome://marionette/content/evaluate.js");
 Cu.import("chrome://marionette/content/event.js");
 Cu.import("chrome://marionette/content/interaction.js");
 Cu.import("chrome://marionette/content/l10n.js");
 Cu.import("chrome://marionette/content/legacyaction.js");
 Cu.import("chrome://marionette/content/logging.js");
@@ -495,26 +496,35 @@ GeckoDriver.prototype.newSession = funct
     throw new SessionNotCreatedError("Maximum number of active sessions.")
   }
   this.sessionId = cmd.parameters.sessionId ||
       cmd.parameters.session_id ||
       element.generateUUID();
 
   this.newSessionCommandId = cmd.id;
   this.setSessionCapabilities(cmd.parameters.capabilities);
+
+  this.scriptTimeout = 10000;
+
+  this.secureTLS = !this.sessionCapabilities.acceptInsecureCerts;
+  if (!this.secureTLS) {
+    logger.warn("TLS certificate errors will be ignored for this session");
+    let acceptAllCerts = new cert.SweepingOverride(
+        cert.Error.Untrusted | cert.Error.Mismatch | cert.Error.Time);
+    cert.installOverride(acceptAllCerts);
+  }
+
   // If we are testing accessibility with marionette, start a11y service in
   // chrome first. This will ensure that we do not have any content-only
   // services hanging around.
   if (this.sessionCapabilities.raisesAccessibilityExceptions &&
       accessibility.service) {
     logger.info("Preemptively starting accessibility service in Chrome");
   }
 
-  this.scriptTimeout = 10000;
-
   let registerBrowsers = this.registerPromise();
   let browserListening = this.listeningPromise();
 
   let waitForWindow = function() {
     let win = this.getCurrentWindow();
     if (!win) {
       // if the window isn't even created, just poll wait for it
       let checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
@@ -653,23 +663,16 @@ GeckoDriver.prototype.setSessionCapabili
         `Not all requiredCapabilities could be met: ${JSON.stringify(errors)}`);
   };
 
   // clone, overwrite, and set
   let caps = copy(this.sessionCapabilities);
   caps = copy(newCaps, caps);
   logger.config("Changing capabilities: " + JSON.stringify(caps));
 
-  // update session state
-  this.secureTLS = !caps.acceptInsecureCerts;
-  if (!this.secureTLS) {
-    logger.warn("Invalid or self-signed TLS certificates " +
-        "will be discarded for this session");
-  }
-
   this.sessionCapabilities = caps;
 };
 
 GeckoDriver.prototype.setUpProxy = function(proxy) {
   logger.config("User-provided proxy settings: " + JSON.stringify(proxy));
 
   assert.object(proxy);
   if (!proxy.hasOwnProperty("proxyType")) {
@@ -2303,17 +2306,19 @@ GeckoDriver.prototype.sessionTearDown = 
   this.sessionId = null;
 
   if (this.observing !== null) {
     for (let topic in this.observing) {
       Services.obs.removeObserver(this.observing[topic], topic);
     }
     this.observing = null;
   }
+
   this.sandboxes.clear();
+  cert.uninstallOverride();
 };
 
 /**
  * Processes the "deleteSession" request from the client by tearing down
  * the session and responding "ok".
  */
 GeckoDriver.prototype.deleteSession = function(cmd, resp) {
   this.sessionTearDown();
--- a/testing/marionette/harness/marionette/tests/unit/test_navigation.py
+++ b/testing/marionette/harness/marionette/tests/unit/test_navigation.py
@@ -1,44 +1,52 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import time
 import urllib
+import contextlib
 
 from marionette import MarionetteTestCase
-from marionette_driver.errors import MarionetteException, TimeoutException
-from marionette_driver import By, Wait
+from marionette_driver import errors, By, Wait
 
 
 def inline(doc):
     return "data:text/html;charset=utf-8,%s" % urllib.quote(doc)
 
 
 class TestNavigate(MarionetteTestCase):
+
     def setUp(self):
         MarionetteTestCase.setUp(self)
         self.marionette.navigate("about:")
         self.test_doc = self.marionette.absolute_url("test.html")
         self.iframe_doc = self.marionette.absolute_url("test_iframe.html")
 
+    @property
+    def location_href(self):
+        return self.marionette.execute_script("return window.location.href")
+
     def test_set_location_through_execute_script(self):
-        self.marionette.execute_script("window.location.href = '%s'" % self.test_doc)
-        Wait(self.marionette).until(lambda _: self.test_doc == self.location_href)
+        self.marionette.execute_script(
+            "window.location.href = '%s'" % self.test_doc)
+        Wait(self.marionette).until(
+            lambda _: self.test_doc == self.location_href)
         self.assertEqual("Marionette Test", self.marionette.title)
 
     def test_navigate(self):
         self.marionette.navigate(self.test_doc)
         self.assertNotEqual("about:", self.location_href)
         self.assertEqual("Marionette Test", self.marionette.title)
 
     def test_navigate_chrome_error(self):
         with self.marionette.using_context("chrome"):
-            self.assertRaisesRegexp(MarionetteException, "Cannot navigate in chrome context",
+            self.assertRaisesRegexp(
+                errors.MarionetteException, "Cannot navigate in chrome context",
                                     self.marionette.navigate, "about:blank")
 
     def test_get_current_url_returns_top_level_browsing_context_url(self):
         self.marionette.navigate(self.iframe_doc)
         self.assertEqual(self.iframe_doc, self.location_href)
         frame = self.marionette.find_element(By.CSS_SELECTOR, "#test_iframe")
         self.marionette.switch_to_frame(frame)
         self.assertEqual(self.iframe_doc, self.marionette.get_url())
@@ -91,55 +99,113 @@ class TestNavigate(MarionetteTestCase):
         self.marionette.navigate(self.marionette.absolute_url("test_iframe.html"))
         self.marionette.switch_to_frame(0)
         self.marionette.navigate(self.marionette.absolute_url("empty.html"))
         self.assertTrue('empty.html' in self.marionette.get_url())
         self.marionette.switch_to_frame()
         self.assertTrue('test_iframe.html' in self.marionette.get_url())
     """
 
-    def test_should_not_error_if_nonexistent_url_used(self):
-        try:
+    def test_invalid_protocol(self):
+        with self.assertRaises(errors.MarionetteException):
             self.marionette.navigate("thisprotocoldoesnotexist://")
-            self.fail("Should have thrown a MarionetteException")
-        except TimeoutException:
-            self.fail("The socket shouldn't have timed out when navigating to a non-existent URL")
-        except MarionetteException as e:
-            self.assertIn("Reached error page", str(e))
-        except Exception as e:
-            import traceback
-            print traceback.format_exc()
-            self.fail("Should have thrown a MarionetteException instead of %s" % type(e))
 
     def test_should_navigate_to_requested_about_page(self):
         self.marionette.navigate("about:neterror")
         self.assertEqual(self.marionette.get_url(), "about:neterror")
         self.marionette.navigate(self.marionette.absolute_url("test.html"))
         self.marionette.navigate("about:blocked")
         self.assertEqual(self.marionette.get_url(), "about:blocked")
 
     def test_find_element_state_complete(self):
         self.marionette.navigate(self.test_doc)
-        state = self.marionette.execute_script("return window.document.readyState")
+        state = self.marionette.execute_script(
+            "return window.document.readyState")
         self.assertEqual("complete", state)
         self.assertTrue(self.marionette.find_element(By.ID, "mozLink"))
 
     def test_error_when_exceeding_page_load_timeout(self):
-        with self.assertRaises(TimeoutException):
+        with self.assertRaises(errors.TimeoutException):
             self.marionette.set_page_load_timeout(0)
             self.marionette.navigate(self.marionette.absolute_url("slow"))
             self.marionette.find_element(By.TAG_NAME, "p")
 
     def test_navigate_iframe(self):
         self.marionette.navigate(self.iframe_doc)
         self.assertTrue('test_iframe.html' in self.marionette.get_url())
         self.assertTrue(self.marionette.find_element(By.ID, "test_iframe"))
 
     def test_fragment(self):
         doc = inline("<p id=foo>")
         self.marionette.navigate(doc)
         self.marionette.execute_script("window.visited = true", sandbox=None)
         self.marionette.navigate("%s#foo" % doc)
-        self.assertTrue(self.marionette.execute_script("return window.visited", sandbox=None))
+        self.assertTrue(self.marionette.execute_script(
+            "return window.visited", sandbox=None))
+
+    def test_error_on_tls_navigation(self):
+        self.assertRaises(errors.InsecureCertificateException,
+                          self.marionette.navigate, self.fixtures.where_is("/test.html", on="https"))
+
+
+class TestTLSNavigation(MarionetteTestCase):
+    insecure_tls = {"acceptInsecureCerts": True}
+    secure_tls = {"acceptInsecureCerts": False}
+
+    def setUp(self):
+        MarionetteTestCase.setUp(self)
+        self.marionette.delete_session()
+        self.capabilities = self.marionette.start_session(
+            desired_capabilities=self.insecure_tls)
+
+    def tearDown(self):
+        try:
+            self.marionette.delete_session()
+        except:
+            pass
+        MarionetteTestCase.tearDown(self)
+
+    @contextlib.contextmanager
+    def safe_session(self):
+        try:
+            self.capabilities = self.marionette.start_session(
+                desired_capabilities=self.secure_tls)
+            yield self.marionette
+        finally:
+            self.marionette.delete_session()
 
-    @property
-    def location_href(self):
-        return self.marionette.execute_script("return window.location.href")
+    @contextlib.contextmanager
+    def unsafe_session(self):
+        try:
+            self.capabilities = self.marionette.start_session(
+                desired_capabilities=self.insecure_tls)
+            yield self.marionette
+        finally:
+            self.marionette.delete_session()
+
+    def test_navigate_by_command(self):
+        self.marionette.navigate(
+            self.fixtures.where_is("/test.html", on="https"))
+        self.assertIn("https", self.marionette.get_url())
+
+    def test_navigate_by_click(self):
+        link_url = self.fixtures.where_is("/test.html", on="https")
+        self.marionette.navigate(
+            inline("<a href=%s>https is the future</a>" % link_url))
+        self.marionette.find_element(By.TAG_NAME, "a").click()
+        self.assertIn("https", self.marionette.get_url())
+
+    def test_deactivation(self):
+        invalid_cert_url = self.fixtures.where_is("/test.html", on="https")
+
+        print "with safe session"
+        with self.safe_session() as session:
+            with self.assertRaises(errors.InsecureCertificateException):
+                session.navigate(invalid_cert_url)
+
+        print "with unsafe session"
+        with self.unsafe_session() as session:
+            session.navigate(invalid_cert_url)
+
+        print "with safe session again"
+        with self.safe_session() as session:
+            with self.assertRaises(errors.InsecureCertificateException):
+                session.navigate(invalid_cert_url)
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -10,16 +10,17 @@ marionette.jar:
   content/legacyaction.js (legacyaction.js)
   content/browser.js (browser.js)
   content/interaction.js (interaction.js)
   content/accessibility.js (accessibility.js)
   content/listener.js (listener.js)
   content/element.js (element.js)
   content/simpletest.js (simpletest.js)
   content/frame.js (frame.js)
+  content/cert.js (cert.js)
   content/event.js  (event.js)
   content/error.js (error.js)
   content/message.js (message.js)
   content/dispatcher.js (dispatcher.js)
   content/modal.js (modal.js)
   content/proxy.js (proxy.js)
   content/capture.js (capture.js)
   content/cookies.js (cookies.js)