Bug 1193341 - Detect presence of password fields in any subframe, flagging those on insecure connections. r=MattN draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Tue, 13 Oct 2015 13:40:34 +0100
changeset 300435 203ca09af7c6270d724e5279937f1dfdb8880ed5
parent 300322 0b69d304f861d0038fb78f1d52b0f5d13ef7c6fe
child 510105 fb0e1362c3e49bc6245927ead8a1103484628c41
push id6323
push userpaolo.mozmail@amadzone.org
push dateTue, 13 Oct 2015 12:54:46 +0000
reviewersMattN
bugs1193341
milestone44.0a1
Bug 1193341 - Detect presence of password fields in any subframe, flagging those on insecure connections. r=MattN
toolkit/components/passwordmgr/InsecurePasswordUtils.jsm
toolkit/components/passwordmgr/LoginManagerContent.jsm
toolkit/components/passwordmgr/LoginManagerParent.jsm
toolkit/components/passwordmgr/test/browser/browser.ini
toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms.js
toolkit/components/passwordmgr/test/browser/insecure_test.html
toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html
--- a/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm
+++ b/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm
@@ -8,16 +8,18 @@ const Ci = Components.interfaces;
 const Cu = Components.utils;
 const Cc = Components.classes;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "devtools",
                                   "resource://gre/modules/devtools/shared/Loader.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerContent",
+                                  "resource://gre/modules/LoginManagerContent.jsm");
 
 Object.defineProperty(this, "WebConsoleUtils", {
   get: function() {
     return devtools.require("devtools/shared/webconsole/utils").Utils;
   },
   configurable: true,
   enumerable: true
 });
@@ -43,60 +45,16 @@ this.InsecurePasswordUtils = {
 
     consoleMsg.initWithWindowID(
       message, "", 0, 0, 0, flag, category, windowId);
 
     Services.console.logMessage(consoleMsg);
   },
 
   /*
-   * Checks whether the passed uri is secure
-   * Check Protocol Flags to determine if scheme is secure:
-   * URI_DOES_NOT_RETURN_DATA - e.g.
-   *   "mailto"
-   * URI_IS_LOCAL_RESOURCE - e.g.
-   *   "data",
-   *   "resource",
-   *   "moz-icon"
-   * URI_INHERITS_SECURITY_CONTEXT - e.g.
-   *   "javascript"
-   * URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT - e.g.
-   *   "https",
-   *   "moz-safe-about"
-   *
-   *   The use of this logic comes directly from nsMixedContentBlocker.cpp
-   *   At the time it was decided to include these protocols since a secure
-   *   uri for mixed content blocker means that the resource can't be
-   *   easily tampered with because 1) it is sent over an encrypted channel or
-   *   2) it is a local resource that never hits the network
-   *   or 3) it is a request sent without any response that could alter
-   *   the behavior of the page. It was decided to include the same logic
-   *   here both to be consistent with MCB and to make sure we cover all
-   *   "safe" protocols. Eventually, the code here and the code in MCB
-   *   will be moved to a common location that will be referenced from
-   *   both places. Look at
-   *   https://bugzilla.mozilla.org/show_bug.cgi?id=899099 for more info.
-   */
-  _checkIfURIisSecure : function(uri) {
-    let isSafe = false;
-    let netutil = Cc["@mozilla.org/network/util;1"].getService(Ci.nsINetUtil);
-    let ph = Ci.nsIProtocolHandler;
-
-    if (netutil.URIChainHasFlags(uri, ph.URI_IS_LOCAL_RESOURCE) ||
-        netutil.URIChainHasFlags(uri, ph.URI_DOES_NOT_RETURN_DATA) ||
-        netutil.URIChainHasFlags(uri, ph.URI_INHERITS_SECURITY_CONTEXT) ||
-        netutil.URIChainHasFlags(uri, ph.URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT)) {
-
-      isSafe = true;
-    }
-
-    return isSafe;
-  },
-
-  /*
    * Checks whether the passed nested document is insecure
    * or is inside an insecure parent document.
    *
    * We check the chain of frame ancestors all the way until the top document
    * because MITM attackers could replace https:// iframes if they are nested inside
    * http:// documents with their own content, thus creating a security risk
    * and potentially stealing user data. Under such scenario, a user might not
    * get a Mixed Content Blocker message, if the main document is served over HTTP
@@ -104,17 +62,17 @@ this.InsecurePasswordUtils = {
    * inside https).
    */
   _checkForInsecureNestedDocuments : function(domDoc) {
     let uri = domDoc.documentURIObject;
     if (domDoc.defaultView == domDoc.defaultView.parent) {
       // We are at the top, nothing to check here
       return false;
     }
-    if (!this._checkIfURIisSecure(uri)) {
+    if (!LoginManagerContent.checkIfURIisSecure(uri)) {
       // We are insecure
       return true;
     }
     // I am secure, but check my parent
     return this._checkForInsecureNestedDocuments(domDoc.defaultView.parent.document);
   },
 
 
@@ -122,17 +80,17 @@ this.InsecurePasswordUtils = {
    * Checks if there are insecure password fields present on the form's document
    * i.e. passwords inside forms with http action, inside iframes with http src,
    * or on insecure web pages. If insecure password fields are present,
    * a log message is sent to the web console to warn developers.
    */
   checkForInsecurePasswords : function (aForm) {
     var domDoc = aForm.ownerDocument;
     let pageURI = domDoc.defaultView.top.document.documentURIObject;
-    let isSafePage = this._checkIfURIisSecure(pageURI);
+    let isSafePage = LoginManagerContent.checkIfURIisSecure(pageURI);
 
     if (!isSafePage) {
       this._sendWebConsoleMessage("InsecurePasswordsPresentOnPage", domDoc);
     }
 
     // Check if we are on an iframe with insecure src, or inside another
     // insecure iframe or document.
     if (this._checkForInsecureNestedDocuments(domDoc)) {
--- a/toolkit/components/passwordmgr/LoginManagerContent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm
@@ -409,25 +409,38 @@ var LoginManagerContent = {
         let loginForm = getFirstLoginForm(frame);
         if (loginForm) {
           return loginForm;
         }
       }
       return null;
     };
 
+    // Returns true if this window or any subframes have insecure login forms.
+    let hasInsecureLoginForms = (thisWindow, parentIsInsecure) => {
+      let doc = thisWindow.document;
+      let isInsecure =
+          parentIsInsecure ||
+          !this.checkIfURIisSecure(doc.documentURIObject);
+      let hasLoginForm = !!this.stateForDocument(doc).loginForm;
+      return (hasLoginForm && isInsecure) ||
+             Array.some(thisWindow.frames,
+                        frame => hasInsecureLoginForms(frame, isInsecure));
+    };
+
     // Store the actual form to use on the state for the top-level document.
     let topState = this.stateForDocument(topWindow.document);
     topState.loginFormForFill = getFirstLoginForm(topWindow);
 
     // Determine whether to show the anchor icon for the current tab.
     let messageManager = messageManagerFromWindow(topWindow);
     messageManager.sendAsyncMessage("RemoteLogins:updateLoginFormPresence", {
       loginFormOrigin,
       loginFormPresent: !!topState.loginFormForFill,
+      hasInsecureLoginForms: hasInsecureLoginForms(topWindow, false),
     });
   },
 
   /**
    * Perform a password fill upon user request coming from the parent process.
    * The fill will be in the form previously identified during page navigation.
    *
    * @param An object with the following properties:
@@ -1084,16 +1097,59 @@ var LoginManagerContent = {
       },
       passwordField: {
         found: !!newPasswordField,
         disabled: newPasswordField && (newPasswordField.disabled || newPasswordField.readOnly),
       },
     };
   },
 
+  /*
+   * Checks whether the passed uri is secure
+   * Check Protocol Flags to determine if scheme is secure:
+   * URI_DOES_NOT_RETURN_DATA - e.g.
+   *   "mailto"
+   * URI_IS_LOCAL_RESOURCE - e.g.
+   *   "data",
+   *   "resource",
+   *   "moz-icon"
+   * URI_INHERITS_SECURITY_CONTEXT - e.g.
+   *   "javascript"
+   * URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT - e.g.
+   *   "https",
+   *   "moz-safe-about"
+   *
+   *   The use of this logic comes directly from nsMixedContentBlocker.cpp
+   *   At the time it was decided to include these protocols since a secure
+   *   uri for mixed content blocker means that the resource can't be
+   *   easily tampered with because 1) it is sent over an encrypted channel or
+   *   2) it is a local resource that never hits the network
+   *   or 3) it is a request sent without any response that could alter
+   *   the behavior of the page. It was decided to include the same logic
+   *   here both to be consistent with MCB and to make sure we cover all
+   *   "safe" protocols. Eventually, the code here and the code in MCB
+   *   will be moved to a common location that will be referenced from
+   *   both places. Look at
+   *   https://bugzilla.mozilla.org/show_bug.cgi?id=899099 for more info.
+   */
+  checkIfURIisSecure : function(uri) {
+    let isSafe = false;
+    let netutil = Cc["@mozilla.org/network/util;1"].getService(Ci.nsINetUtil);
+    let ph = Ci.nsIProtocolHandler;
+
+    if (netutil.URIChainHasFlags(uri, ph.URI_IS_LOCAL_RESOURCE) ||
+        netutil.URIChainHasFlags(uri, ph.URI_DOES_NOT_RETURN_DATA) ||
+        netutil.URIChainHasFlags(uri, ph.URI_INHERITS_SECURITY_CONTEXT) ||
+        netutil.URIChainHasFlags(uri, ph.URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT)) {
+
+      isSafe = true;
+    }
+
+    return isSafe;
+  },
 };
 
 var LoginUtils = {
   /*
    * _getPasswordOrigin
    *
    * Get the parts of the URL we want for identification.
    */
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -565,31 +565,47 @@ var LoginManagerParent = {
     if (!loginFormState) {
       loginFormState = {};
       this.loginFormStateByBrowser.set(browser, loginFormState);
     }
     return loginFormState;
   },
 
   /**
+   * Returns true if the page currently loaded in the given browser element has
+   * insecure login forms. This state may be updated asynchronously, in which
+   * case a custom event named InsecureLoginFormsStateChange will be dispatched
+   * on the browser element.
+   */
+  hasInsecureLoginForms(browser) {
+    return !!this.stateForBrowser(browser).hasInsecureLoginForms;
+  },
+
+  /**
    * Called to indicate whether a login form on the currently loaded page is
    * present or not. This is one of the factors used to control the visibility
    * of the password fill doorhanger.
    */
-  updateLoginFormPresence(browser, { loginFormOrigin, loginFormPresent }) {
+  updateLoginFormPresence(browser, { loginFormOrigin, loginFormPresent,
+                                     hasInsecureLoginForms }) {
     const ANCHOR_DELAY_MS = 200;
 
     let state = this.stateForBrowser(browser);
 
     // Update the data to use to the latest known values. Since messages are
     // processed in order, this will always be the latest version to use.
     state.loginFormOrigin = loginFormOrigin;
     state.loginFormPresent = loginFormPresent;
+    state.hasInsecureLoginForms = hasInsecureLoginForms;
 
-    // Apply the data to the currently displayed icon later.
+    // Report the insecure login form state immediately.
+    browser.dispatchEvent(new browser.ownerDocument.defaultView
+                                 .CustomEvent("InsecureLoginFormsStateChange"));
+
+    // Apply the data to the currently displayed login fill icon later.
     if (!state.anchorDeferredTask) {
       state.anchorDeferredTask = new DeferredTask(
         () => this.updateLoginAnchor(browser),
         ANCHOR_DELAY_MS
       );
     }
     state.anchorDeferredTask.arm();
   },
--- a/toolkit/components/passwordmgr/test/browser/browser.ini
+++ b/toolkit/components/passwordmgr/test/browser/browser.ini
@@ -1,17 +1,20 @@
 [DEFAULT]
 support-files =
   authenticate.sjs
   form_basic.html
+  insecure_test.html
+  insecure_test_subframe.html
   multiple_forms.html
 
 [browser_DOMFormHasPassword.js]
 [browser_DOMInputPasswordAdded.js]
 [browser_filldoorhanger.js]
+[browser_hasInsecureLoginForms.js]
 [browser_notifications.js]
 skip-if = true # Intermittent failures: Bug 1182296, bug 1148771
 [browser_passwordmgr_editing.js]
 skip-if = os == "linux"
 [browser_context_menu.js]
 skip-if = os == "linux"
 [browser_passwordmgr_contextmenu.js]
 [browser_passwordmgr_fields.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_hasInsecureLoginForms.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/LoginManagerParent.jsm", this);
+
+const testUrlPath =
+      "://example.com/browser/toolkit/components/passwordmgr/test/browser/";
+
+/**
+ * Waits for the given number of occurrences of InsecureLoginFormsStateChange
+ * on the given browser element.
+ */
+function waitForInsecureLoginFormsStateChange(browser, count) {
+  return BrowserTestUtils.waitForEvent(browser, "InsecureLoginFormsStateChange",
+                                       false, () => --count == 0);
+}
+
+/**
+ * Checks that hasInsecureLoginForms is true for a simple HTTP page and false
+ * for a simple HTTPS page.
+ */
+add_task(function* test_simple() {
+  for (let scheme of ["http", "https"]) {
+    let tab = gBrowser.addTab(scheme + testUrlPath + "form_basic.html");
+    let browser = tab.linkedBrowser;
+    yield Promise.all([
+      BrowserTestUtils.switchTab(gBrowser, tab),
+      BrowserTestUtils.browserLoaded(browser),
+      // One event is triggered by pageshow and one by DOMFormHasPassword.
+      waitForInsecureLoginFormsStateChange(browser, 2),
+    ]);
+
+    Assert.equal(LoginManagerParent.hasInsecureLoginForms(browser),
+                 scheme == "http");
+
+    gBrowser.removeTab(tab);
+  }
+});
+
+/**
+ * Checks that hasInsecureLoginForms is true if a password field is present in
+ * an HTTP page loaded as a subframe of a top-level HTTPS page, when mixed
+ * active content blocking is disabled.
+ *
+ * When the subframe is navigated to an HTTPS page, hasInsecureLoginForms should
+ * be set to false.
+ *
+ * Moving back in history should set hasInsecureLoginForms to true again.
+ */
+add_task(function* test_subframe_navigation() {
+  yield new Promise(resolve => SpecialPowers.pushPrefEnv({
+    "set": [["security.mixed_content.block_active_content", false]],
+  }, resolve));
+
+  // Load the page with the subframe in a new tab.
+  let tab = gBrowser.addTab("https" + testUrlPath + "insecure_test.html");
+  let browser = tab.linkedBrowser;
+  yield Promise.all([
+    BrowserTestUtils.switchTab(gBrowser, tab),
+    BrowserTestUtils.browserLoaded(browser),
+    // Two events are triggered by pageshow and one by DOMFormHasPassword.
+    waitForInsecureLoginFormsStateChange(browser, 3),
+  ]);
+
+  Assert.ok(LoginManagerParent.hasInsecureLoginForms(browser));
+
+  // Navigate the subframe to a secure page.
+  let promiseSubframeReady = Promise.all([
+    BrowserTestUtils.browserLoaded(browser, true),
+    // One event is triggered by pageshow and one by DOMFormHasPassword.
+    waitForInsecureLoginFormsStateChange(browser, 2),
+  ]);
+  yield ContentTask.spawn(browser, null, function* () {
+    content.document.getElementById("test-iframe")
+           .contentDocument.getElementById("test-link").click();
+  });
+  yield promiseSubframeReady;
+
+  Assert.ok(!LoginManagerParent.hasInsecureLoginForms(browser));
+
+  // Navigate back to the insecure page. We only have to wait for the
+  // InsecureLoginFormsStateChange event that is triggered by pageshow.
+  let promise = waitForInsecureLoginFormsStateChange(browser, 1);
+  yield ContentTask.spawn(browser, null, function* () {
+    content.document.getElementById("test-iframe")
+           .contentWindow.history.back();
+  });
+  yield promise;
+
+  Assert.ok(LoginManagerParent.hasInsecureLoginForms(browser));
+
+  gBrowser.removeTab(tab);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/insecure_test.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+   - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- This frame is initially loaded over HTTP. -->
+<iframe id="test-iframe"
+        src="http://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html"/>
+
+</body></html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+   - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<form>
+  <input name="password" type="password">
+</form>
+
+<!-- Link to reload this page over HTTPS. -->
+<a id="test-link"
+   href="https://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html">HTTPS</a>
+
+</body></html>