Bug 1295607 - Avoid CSP errors when drawing the window into the eyedropper; r=miker draft
authorPatrick Brosset <pbrosset@mozilla.com>
Thu, 18 Aug 2016 14:37:04 +0200
changeset 402556 f2cbe6da04961cbabd661e3ab22a74d9bee0d7e6
parent 402555 ae57739842788f7d235748762d62f912a60e4ecc
child 528713 d67299486add507902d97ca26a7b3faed30bc00a
push id26703
push userpbrosset@mozilla.com
push dateThu, 18 Aug 2016 13:20:44 +0000
reviewersmiker
bugs1295607
milestone51.0a1
Bug 1295607 - Avoid CSP errors when drawing the window into the eyedropper; r=miker Pages defining CSP response headers used to be a problem for the eyedropper. Indeed, the eyedropper would take a screenshot of the window with canvas.drawWindow and then load the resulting data as an Image. But in order to access the Image() constructor, it would use the content window: new window.Image(), and that wasn't possible with CSP headers. With this change, the eyedropper creates an ImageBitmap with window.createImageBitmap() and that doesn't cause CSP errors, and still works fine because ImageBitmap are consumable by the eyedropper. This change also adds a new test to prevent this bug from coming back. MozReview-Commit-ID: 7f3HCXJtTiv
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js
devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-csp.js
devtools/client/inspector/test/doc_inspector_csp.html
devtools/client/inspector/test/doc_inspector_csp.html^headers^
devtools/client/inspector/test/head.js
devtools/server/actors/highlighters/eye-dropper.js
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -1,15 +1,17 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
 support-files =
   doc_inspector_add_node.html
   doc_inspector_breadcrumbs.html
   doc_inspector_breadcrumbs_visibility.html
+  doc_inspector_csp.html
+  doc_inspector_csp.html^headers^
   doc_inspector_delete-selected-node-01.html
   doc_inspector_delete-selected-node-02.html
   doc_inspector_embed.html
   doc_inspector_gcli-inspect-command.html
   doc_inspector_highlight_after_transition.html
   doc_inspector_highlighter-comments.html
   doc_inspector_highlighter-geometry_01.html
   doc_inspector_highlighter-geometry_02.html
@@ -63,16 +65,17 @@ skip-if = os == "mac" # Full keyboard na
 [browser_inspector_highlighter-04.js]
 [browser_inspector_highlighter-by-type.js]
 [browser_inspector_highlighter-comments.js]
 [browser_inspector_highlighter-csstransform_01.js]
 [browser_inspector_highlighter-csstransform_02.js]
 [browser_inspector_highlighter-embed.js]
 [browser_inspector_highlighter-eyedropper-clipboard.js]
 subsuite = clipboard
+[browser_inspector_highlighter-eyedropper-csp.js]
 [browser_inspector_highlighter-eyedropper-events.js]
 [browser_inspector_highlighter-eyedropper-label.js]
 [browser_inspector_highlighter-eyedropper-show-hide.js]
 [browser_inspector_highlighter-geometry_01.js]
 [browser_inspector_highlighter-geometry_02.js]
 [browser_inspector_highlighter-geometry_03.js]
 [browser_inspector_highlighter-geometry_04.js]
 [browser_inspector_highlighter-geometry_05.js]
--- a/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js
@@ -9,17 +9,18 @@ const HIGHLIGHTER_TYPE = "EyeDropper";
 const ID = "eye-dropper-";
 const TEST_URI = "data:text/html;charset=utf-8,<style>html{background:red}</style>";
 
 add_task(function* () {
   let helper = yield openInspectorForURL(TEST_URI)
                .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
   helper.prefix = ID;
 
-  let {show, finalize} = helper;
+  let {show, finalize,
+       waitForElementAttributeSet, waitForElementAttributeRemoved} = helper;
 
   info("Show the eyedropper with the copyOnSelect option");
   yield show("html", {copyOnSelect: true});
 
   info("Make sure to wait until the eyedropper is done taking a screenshot of the page");
   yield waitForElementAttributeSet("root", "drawn", helper);
 
   yield waitForClipboard(() => {
@@ -31,34 +32,8 @@ add_task(function* () {
 
   yield waitForElementAttributeRemoved("root", "drawn", helper);
   yield waitForElementAttributeSet("root", "hidden", helper);
   ok(true, "The eyedropper is now hidden");
 
   finalize();
 });
 
-function* waitForElementAttributeSet(id, name, {getElementAttribute}) {
-  yield poll(function* () {
-    let value = yield getElementAttribute(id, name);
-    return !!value;
-  }, `Waiting for element ${id} to have attribute ${name} set`);
-}
-
-function* waitForElementAttributeRemoved(id, name, {getElementAttribute}) {
-  yield poll(function* () {
-    let value = yield getElementAttribute(id, name);
-    return !value;
-  }, `Waiting for element ${id} to have attribute ${name} removed`);
-}
-
-function* poll(check, desc) {
-  info(desc);
-
-  for (let i = 0; i < 10; i++) {
-    if (yield check()) {
-      return;
-    }
-    yield new Promise(resolve => setTimeout(resolve, 200));
-  }
-
-  throw new Error(`Timeout while: ${desc}`);
-}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-csp.js
@@ -0,0 +1,30 @@
+/* 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";
+
+// Test that the eyedropper opens correctly even when the page defines CSP headers.
+
+const HIGHLIGHTER_TYPE = "EyeDropper";
+const ID = "eye-dropper-";
+const TEST_URI = URL_ROOT + "doc_inspector_csp.html";
+
+add_task(function* () {
+  let helper = yield openInspectorForURL(TEST_URI)
+               .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+  helper.prefix = ID;
+  let {show, hide, finalize, isElementHidden, waitForElementAttributeSet} = helper;
+
+  info("Try to display the eyedropper");
+  yield show("html");
+
+  let hidden = yield isElementHidden("root");
+  ok(!hidden, "The eyedropper is now shown");
+
+  info("Wait until the eyedropper is done taking a screenshot of the page");
+  yield waitForElementAttributeSet("root", "drawn", helper);
+  ok(true, "The image data was retrieved successfully from the window");
+
+  yield hide();
+  finalize();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/doc_inspector_csp.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Inspector CSP Test</title>
+    <meta charset="utf-8">
+  </head>
+  <body>
+    This HTTP response has CSP headers.
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/doc_inspector_csp.html^headers^
@@ -0,0 +1,2 @@
+Content-Type: text/html; charset=UTF-8
+content-security-policy: default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self';
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -390,16 +390,38 @@ function* getNodeFrontForSelector(select
   }
 
   info("Retrieving front for doctype node");
   let {nodes} = yield inspector.walker.children(inspector.walker.rootNode);
   return nodes[0];
 }
 
 /**
+ * A simple polling helper that executes a given function until it returns true.
+ * @param {Function} check A generator function that is expected to return true at some
+ * stage.
+ * @param {String} desc A text description to be displayed when the polling starts.
+ * @param {Number} attemptes Optional number of times we poll. Defaults to 10.
+ * @param {Number} timeBetweenAttempts Optional time to wait between each attempt.
+ * Defaults to 200ms.
+ */
+function* poll(check, desc, attempts = 10, timeBetweenAttempts = 200) {
+  info(desc);
+
+  for (let i = 0; i < attempts; i++) {
+    if (yield check()) {
+      return;
+    }
+    yield new Promise(resolve => setTimeout(resolve, timeBetweenAttempts));
+  }
+
+  throw new Error(`Timeout while: ${desc}`);
+}
+
+/**
  * Encapsulate some common operations for highlighter's tests, to have
  * the tests cleaner, without exposing directly `inspector`, `highlighter`, and
  * `testActor` if not needed.
  *
  * @param  {String}
  *    The highlighter's type
  * @return
  *    A generator function that takes an object with `inspector` and `testActor`
@@ -455,16 +477,32 @@ const getHighlighterHelperFor = (type) =
           prefix + id, highlighter);
       },
 
       getElementAttribute: function* (id, name) {
         return yield testActor.getHighlighterNodeAttribute(
           prefix + id, name, highlighter);
       },
 
+      waitForElementAttributeSet: function* (id, name) {
+        yield poll(function* () {
+          let value = yield testActor.getHighlighterNodeAttribute(
+            prefix + id, name, highlighter);
+          return !!value;
+        }, `Waiting for element ${id} to have attribute ${name} set`);
+      },
+
+      waitForElementAttributeRemoved: function* (id, name) {
+        yield poll(function* () {
+          let value = yield testActor.getHighlighterNodeAttribute(
+            prefix + id, name, highlighter);
+          return !value;
+        }, `Waiting for element ${id} to have attribute ${name} removed`);
+      },
+
       synthesizeMouse: function* (options) {
         options = Object.assign({selector: ":root"}, options);
         yield testActor.synthesizeMouse(options);
       },
 
       // This object will synthesize any "mouse" prefixed event to the
       // `testActor`, using the name of method called as suffix for the
       // event's name.
--- a/devtools/server/actors/highlighters/eye-dropper.js
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -176,31 +176,31 @@ EyeDropper.prototype = {
     pageListenerTarget.removeEventListener("DOMMouseScroll", this);
     pageListenerTarget.removeEventListener("FullZoomChange", this);
 
     this.getElement("root").setAttribute("hidden", "true");
     this.getElement("root").removeAttribute("drawn");
   },
 
   prepareImageCapture() {
-    // Get the page as an image.
+    // Get the image data from the content window.
     let imageData = getWindowAsImageData(this.win);
-    let image = new this.win.Image();
-    image.src = imageData;
 
-    // Wait for screenshot to load
-    image.onload = () => {
+    // We need to transform imageData to something drawWindow will consume. An ImageBitmap
+    // works well. We could have used an Image, but doing so results in errors if the page
+    // defines CSP headers.
+    this.win.createImageBitmap(imageData).then(image => {
       this.pageImage = image;
       // We likely haven't drawn anything yet (no mousemove events yet), so start now.
       this.draw();
 
       // Set an attribute on the root element to be able to run tests after the first draw
       // was done.
       this.getElement("root").setAttribute("drawn", "true");
-    };
+    });
   },
 
   /**
    * Get the number of cells (blown-up pixels) per direction in the grid.
    */
   get cellsWide() {
     // Canvas will render whole "pixels" (cells) only, and an even number at that. Round
     // up to the nearest even number of pixels.
@@ -451,35 +451,35 @@ EyeDropper.prototype = {
       this._copyTimeout = setTimeout(resolve, CLOSE_DELAY);
     });
   }
 };
 
 exports.EyeDropper = EyeDropper;
 
 /**
- * Get a content window as image data-url.
+ * Draw the visible portion of the window on a canvas and get the resulting ImageData.
  * @param {Window} win
- * @return {String} The data-url
+ * @return {ImageData} The image data for the window.
  */
 function getWindowAsImageData(win) {
   let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
   let scale = getCurrentZoom(win);
   let width = win.innerWidth;
   let height = win.innerHeight;
   canvas.width = width * scale;
   canvas.height = height * scale;
   canvas.mozOpaque = true;
 
   let ctx = canvas.getContext("2d");
 
   ctx.scale(scale, scale);
   ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff");
 
-  return canvas.toDataURL();
+  return ctx.getImageData(0, 0, canvas.width, canvas.height);
 }
 
 /**
  * Get a formatted CSS color string from a color value.
  * @param {array} rgb Rgb values of a color to format.
  * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name".
  * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)".
  */