bug 1272890 - implement match_about_blank for content scripts r?kmag draft 1272890-match_about_blank
authorTomislav Jovanovic <tomica@gmail.com>
Wed, 12 Oct 2016 05:48:04 +0200
changeset 431367 bb83c68517f94cf4bb27fc82b8f387f68ca5c17c
parent 431012 1561c917ee27c3ea04bd69467e5b8c7c08102f2a
child 535401 ce48f1feeddd68c60c23836e771f41df3a6f159c
push id34040
push userbmo:tomica@gmail.com
push dateSat, 29 Oct 2016 12:04:58 +0000
reviewerskmag
bugs1272890
milestone52.0a1
bug 1272890 - implement match_about_blank for content scripts r?kmag MozReview-Commit-ID: 3iZLpUw5LF4
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/test/mochitest/file_with_about_blank.html
toolkit/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -96,16 +96,17 @@ var apiManager = new class extends Schem
 // Represents a content script.
 function Script(extension, options, deferred = PromiseUtils.defer()) {
   this.extension = extension;
   this.options = options;
   this.run_at = this.options.run_at;
   this.js = this.options.js || [];
   this.css = this.options.css || [];
   this.remove_css = this.options.remove_css;
+  this.match_about_blank = this.options.match_about_blank;
 
   this.deferred = deferred;
 
   this.matches_ = new MatchPattern(this.options.matches);
   this.exclude_matches_ = new MatchPattern(this.options.exclude_matches || null);
   // TODO: MatchPattern should pre-mangle host-only patterns so that we
   // don't need to call a separate match function.
   this.matches_host_ = new MatchPattern(this.options.matchesHost || null);
@@ -135,16 +136,22 @@ Script.prototype = {
     let uri = window.document.documentURIObject;
 
     // If mozAddonManager is present on this page, don't allow
     // content scripts.
     if (window.navigator.mozAddonManager !== undefined) {
       return false;
     }
 
+    if (this.match_about_blank && ["about:blank", "about:srcdoc"].includes(uri.spec)) {
+      // When matching about:blank/srcdoc documents, the checks below
+      // need to be performed against the "owner" document's URI.
+      uri = window.document.nodePrincipal.URI;
+    }
+
     if (!(this.matches_.matches(uri) || this.matches_host_.matchesIgnoringPath(uri))) {
       return false;
     }
 
     if (this.exclude_matches_.matches(uri)) {
       return false;
     }
 
@@ -161,18 +168,16 @@ Script.prototype = {
     if (this.options.frame_id != null) {
       if (WebNavigationFrames.getFrameId(window) != this.options.frame_id) {
         return false;
       }
     } else if (!this.options.all_frames && window.top != window) {
       return false;
     }
 
-    // TODO: match_about_blank.
-
     return true;
   },
 
   cleanup(window) {
     if (!this.remove_css) {
       let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIDOMWindowUtils);
 
@@ -421,73 +426,91 @@ DocumentManager = {
 
   // Map[windowId -> Map[extensionId -> ExtensionContext]]
   contentScriptWindows: new Map(),
 
   // Map[windowId -> ExtensionContext]
   extensionPageWindows: new Map(),
 
   init() {
+    Services.obs.addObserver(this, "content-document-global-created", false);
     Services.obs.addObserver(this, "document-element-inserted", false);
     Services.obs.addObserver(this, "inner-window-destroyed", false);
   },
 
   uninit() {
+    Services.obs.removeObserver(this, "content-document-global-created");
     Services.obs.removeObserver(this, "document-element-inserted");
     Services.obs.removeObserver(this, "inner-window-destroyed");
   },
 
   getWindowState(contentWindow) {
     let readyState = contentWindow.document.readyState;
     if (readyState == "complete") {
       return "document_idle";
     }
     if (readyState == "interactive") {
       return "document_end";
     }
     return "document_start";
   },
 
+  loadInto(window) {
+    // Enable the content script APIs should be available in subframes' window
+    // if it is recognized as a valid addon id (see Bug 1214658 for rationale).
+    const {
+      NO_PRIVILEGES,
+      CONTENTSCRIPT_PRIVILEGES,
+      FULL_PRIVILEGES,
+    } = ExtensionManagement.API_LEVELS;
+    let extensionId = ExtensionManagement.getAddonIdForWindow(window);
+    let apiLevel = ExtensionManagement.getAPILevelForWindow(window, extensionId);
+
+    if (apiLevel != NO_PRIVILEGES) {
+      let extension = ExtensionManager.get(extensionId);
+      if (extension) {
+        if (apiLevel == CONTENTSCRIPT_PRIVILEGES) {
+          DocumentManager.getExtensionPageContext(extension, window);
+        } else if (apiLevel == FULL_PRIVILEGES) {
+          ExtensionChild.createExtensionContext(extension, window);
+        }
+      }
+    }
+  },
+
   observe: function(subject, topic, data) {
-    if (topic == "document-element-inserted") {
+    // For some types of documents (about:blank), we only see the first
+    // notification, for others (data: URIs) we only observe the second.
+    if (topic == "content-document-global-created" || topic == "document-element-inserted") {
       let document = subject;
       let window = document && document.defaultView;
+
+      if (topic == "content-document-global-created") {
+        window = subject;
+        document = window && window.document;
+      }
+
       if (!document || !document.location || !window) {
         return;
       }
 
       // Make sure we only load into frames that ExtensionContent.init
       // was called on (i.e., not frames for social or sidebars).
       let mm = getWindowMessageManager(window);
       if (!mm || !ExtensionContent.globals.has(mm)) {
         return;
       }
 
-      // Enable the content script APIs should be available in subframes' window
-      // if it is recognized as a valid addon id (see Bug 1214658 for rationale).
-      const {
-        NO_PRIVILEGES,
-        CONTENTSCRIPT_PRIVILEGES,
-        FULL_PRIVILEGES,
-      } = ExtensionManagement.API_LEVELS;
-      let extensionId = ExtensionManagement.getAddonIdForWindow(window);
-      let apiLevel = ExtensionManagement.getAPILevelForWindow(window, extensionId);
-
-      if (apiLevel != NO_PRIVILEGES) {
-        let extension = ExtensionManager.get(extensionId);
-        if (extension) {
-          if (apiLevel == CONTENTSCRIPT_PRIVILEGES) {
-            DocumentManager.getExtensionPageContext(extension, window);
-          } else if (apiLevel == FULL_PRIVILEGES) {
-            ExtensionChild.createExtensionContext(extension, window);
-          }
-        }
+      // Load on document-element-inserted, except for about:blank which doesn't
+      // see it, and needs special late handling on DOMContentLoaded event.
+      if (topic === "document-element-inserted") {
+        this.loadInto(window);
+        this.trigger("document_start", window);
       }
 
-      this.trigger("document_start", window);
       /* eslint-disable mozilla/balanced-listeners */
       window.addEventListener("DOMContentLoaded", this, true);
       window.addEventListener("load", this, true);
       /* eslint-enable mozilla/balanced-listeners */
     } else if (topic == "inner-window-destroyed") {
       let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
 
       MessageChannel.abortResponses({innerWindowID: windowId});
@@ -521,16 +544,22 @@ DocumentManager = {
       // listening on.
       return;
     }
     window.removeEventListener(event.type, this, true);
 
     // Need to check if we're still on the right page? Greasemonkey does this.
 
     if (event.type == "DOMContentLoaded") {
+      // By this time, we can be sure if this is an explicit about:blank
+      // document, and if it needs special late loading and fake trigger.
+      if (window.location.href === "about:blank") {
+        this.loadInto(window);
+        this.trigger("document_start", window);
+      }
       this.trigger("document_end", window);
     } else if (event.type == "load") {
       this.trigger("document_idle", window);
     }
   },
 
   // Used to executeScript, insertCSS and removeCSS.
   executeScript(global, extensionId, options) {
@@ -662,31 +691,29 @@ DocumentManager = {
 
     this.extensionCount--;
     if (this.extensionCount == 0) {
       this.uninit();
     }
   },
 
   trigger(when, window) {
-    let state = this.getWindowState(window);
-
-    if (state == "document_start") {
+    if (when === "document_start") {
       for (let extension of ExtensionManager.extensions.values()) {
         for (let script of extension.scripts) {
           if (script.matches(window)) {
             let context = this.getContentScriptContext(extension, window);
             context.addScript(script);
           }
         }
       }
     } else {
       let contexts = this.contentScriptWindows.get(getInnerWindowID(window)) || new Map();
       for (let context of contexts.values()) {
-        context.triggerScripts(state);
+        context.triggerScripts(this.getWindowState(window));
       }
     }
   },
 };
 
 // Represents a browser extension in the content process.
 function BrowserExtensionContent(data) {
   this.id = data.id;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+</head>
+<body>
+  <iframe id="a_b" src="about:blank"></iframe>
+  <iframe srcdoc="galactica actual" src="adama"></iframe>
+</body>
+</html>
--- a/toolkit/components/extensions/test/mochitest/mochitest.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -13,16 +13,17 @@ support-files =
   file_webNavigation_frameClientRedirect.html
   file_webNavigation_frameRedirect.html
   file_webNavigation_manualSubframe.html
   file_webNavigation_manualSubframe_page1.html
   file_webNavigation_manualSubframe_page2.html
   file_WebNavigation_page1.html
   file_WebNavigation_page2.html
   file_WebNavigation_page3.html
+  file_with_about_blank.html
   file_image_good.png
   file_image_bad.png
   file_image_redirect.png
   file_style_good.css
   file_style_bad.css
   file_style_redirect.css
   file_script_good.js
   file_script_bad.js
@@ -45,16 +46,17 @@ skip-if = os == 'android' # Android does
 [test_ext_content_security_policy.html]
 [test_ext_contentscript.html]
 [test_ext_contentscript_api_injection.html]
 [test_ext_contentscript_context.html]
 [test_ext_contentscript_create_iframe.html]
 [test_ext_contentscript_devtools_metadata.html]
 [test_ext_contentscript_exporthelpers.html]
 [test_ext_contentscript_css.html]
+[test_ext_contentscript_about_blank.html]
 [test_ext_contentscript_teardown.html]
 skip-if = (os == 'android') # Android does not support tabs API. Bug 1260250
 [test_ext_exclude_include_globs.html]
 [test_ext_i18n_css.html]
 [test_ext_generate.html]
 [test_ext_notifications.html]
 [test_ext_permission_xhr.html]
 [test_ext_runtime_connect.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
@@ -0,0 +1,117 @@
+<!doctype html>
+<html>
+<head>
+  <title>Test content script match_about_blank option</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(function* test_contentscript_about_blank() {
+  const manifest = {
+    content_scripts: [
+      {
+        match_about_blank: true,
+        matches: ["http://mochi.test/*/file_with_about_blank.html", "http://example.com/*"],
+        all_frames: true,
+        css: ["all.css"],
+        js: ["all.js"],
+      }, {
+        matches: ["http://mochi.test/*/file_with_about_blank.html"],
+        css: ["mochi_without.css"],
+        js: ["mochi_without.js"],
+        all_frames: true,
+      }, {
+        match_about_blank: true,
+        matches: ["http://mochi.test/*/file_with_about_blank.html"],
+        css: ["mochi_with.css"],
+        js: ["mochi_with.js"],
+        all_frames: true,
+      },
+    ],
+  };
+
+  const files = {
+    "all.js": function() {
+      browser.runtime.sendMessage("all");
+    },
+    "all.css": `
+      body { color: red; }
+    `,
+    "mochi_without.js": function() {
+      browser.runtime.sendMessage("mochi_without");
+    },
+    "mochi_without.css": `
+      body { background: yellow; }
+    `,
+    "mochi_with.js": function() {
+      browser.runtime.sendMessage("mochi_with");
+    },
+    "mochi_with.css": `
+      body { text-align: right; }
+    `,
+  };
+
+  function background() {
+    browser.runtime.onMessage.addListener((script, {url}) => {
+      const kind = url.startsWith("about:") ? url : "top";
+      browser.test.sendMessage("script", [script, kind, url]);
+      browser.test.sendMessage(`${script}:${kind}`);
+    });
+  }
+
+  const PATH = "tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html";
+  const extension = ExtensionTestUtils.loadExtension({manifest, files, background});
+  yield extension.startup();
+
+  let count = 0;
+  extension.onMessage("script", script => {
+    info(`script ran: ${script}`);
+    count++;
+  });
+
+  let win = window.open("http://example.com/" + PATH);
+  yield Promise.all([
+    extension.awaitMessage("all:top"),
+    extension.awaitMessage("all:about:blank"),
+    extension.awaitMessage("all:about:srcdoc"),
+  ]);
+  is(count, 3, "exactly 3 scripts ran");
+  win.close();
+
+  win = window.open("http://mochi.test:8888/" + PATH);
+  yield Promise.all([
+    extension.awaitMessage("all:top"),
+    extension.awaitMessage("all:about:blank"),
+    extension.awaitMessage("all:about:srcdoc"),
+    extension.awaitMessage("mochi_without:top"),
+    extension.awaitMessage("mochi_with:top"),
+    extension.awaitMessage("mochi_with:about:blank"),
+    extension.awaitMessage("mochi_with:about:srcdoc"),
+  ]);
+
+  let style = win.getComputedStyle(win.document.body);
+  is(style.color, "rgb(255, 0, 0)", "top window text color is red");
+  is(style.backgroundColor, "rgb(255, 255, 0)", "top window background is yellow");
+  is(style.textAlign, "right", "top window text is right-aligned");
+
+  let a_b = win.document.getElementById("a_b");
+  style = a_b.contentWindow.getComputedStyle(a_b.contentDocument.body);
+  is(style.color, "rgb(255, 0, 0)", "about:blank iframe text color is red");
+  is(style.backgroundColor, "transparent", "about:blank iframe background is transparent");
+  is(style.textAlign, "right", "about:blank text is right-aligned");
+
+  is(count, 10, "exactly 7 more scripts ran");
+  win.close();
+
+  yield extension.unload();
+});
+</script>
+
+</body>
+</html>