Bug 1215376 - Add more contextual information to onShown draft
authorRob Wu <rob@robwu.nl>
Thu, 14 Sep 2017 16:37:57 +0200
changeset 716035 b2c34f6ba40c0c2f4544238986127147e255a13b
parent 716034 63cf5f0bd8cfaa2d6ba76a37bf1ab779c7e10dbb
child 716036 94ec7d0b50e338850b397a0674ae6adcc6e70d95
push id94307
push userbmo:rob@robwu.nl
push dateFri, 05 Jan 2018 00:24:53 +0000
bugs1215376
milestone59.0a1
Bug 1215376 - Add more contextual information to onShown - Adds most of the OnClickData properties to the onShown event parameter (except for menu-specific properties such as parentMenuId / checked). - Add tests to verify the values of these properties. MozReview-Commit-ID: 7705sJyAOIJ
browser/components/extensions/ext-menus.js
browser/components/extensions/schemas/menus.json
browser/components/extensions/test/browser/browser_ext_menus_events.js
--- a/browser/components/extensions/ext-menus.js
+++ b/browser/components/extensions/ext-menus.js
@@ -382,26 +382,19 @@ var gMenuBuilder = {
   afterBuildingMenu(contextData) {
     // Left-hand side is an optimization: No need to construct event details if
     // nobody is subscribing to the event.
     // Right-hand side is to prevent onShown from being fired when a menu is
     // updated.
     if (gShownMenuItems.size === 0 || this.contextData) {
       return;
     }
-    let commonMenuInfo = {
-      contexts: Array.from(getMenuContexts(contextData)),
-    };
-    // TODO(robwu): Add more contextual information.
-    // The menus.onShown event is fired before the user has consciously
-    // interacted with an extension, so beware of privacy implications of
-    // sharing event data without permission checks.
+
     for (let [extension, menuIds] of gShownMenuItems.entries()) {
-      let info = Object.assign({menuIds}, commonMenuInfo);
-      extension.emit("webext-menu-shown", info);
+      extension.emit("webext-menu-shown", menuIds, contextData);
     }
 
     this.contextData = contextData;
   },
 
   handleEvent(event) {
     if (this.xulMenu != event.target || event.type != "popuphidden") {
       return;
@@ -464,16 +457,46 @@ const getMenuContexts = contextData => {
   // New non-content contexts supported in Firefox are not part of "all".
   if (!contextData.onBookmark && !contextData.onTab && !contextData.inToolsMenu) {
     contexts.add("all");
   }
 
   return contexts;
 };
 
+function addMenuEventInfo(info, contextData, includeSensitiveData) {
+  if (contextData.onVideo) {
+    info.mediaType = "video";
+  } else if (contextData.onAudio) {
+    info.mediaType = "audio";
+  } else if (contextData.onImage) {
+    info.mediaType = "image";
+  }
+  if (contextData.frameId !== undefined) {
+    info.frameId = contextData.frameId;
+  }
+  info.editable = contextData.onEditableArea || contextData.onPassword || false;
+  if (includeSensitiveData) {
+    if (contextData.onLink) {
+      info.linkText = contextData.linkText;
+      info.linkUrl = contextData.linkUrl;
+    }
+    if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+      info.srcUrl = contextData.srcUrl;
+    }
+    info.pageUrl = contextData.pageUrl;
+    if (contextData.inFrame) {
+      info.frameUrl = contextData.frameUrl;
+    }
+    if (contextData.isTextSelected) {
+      info.selectionText = contextData.selectionText;
+    }
+  }
+}
+
 function MenuItem(extension, createProperties, isRoot = false) {
   this.extension = extension;
   this.children = [];
   this.parent = null;
   this.tabManager = extension.tabManager;
 
   this.setDefaults();
   this.setProps(createProperties);
@@ -796,18 +819,33 @@ this.menusInternal = class extends Exten
     let {extension} = context;
 
     const menus = {
       refresh() {
         gMenuBuilder.rebuildMenu(extension);
       },
 
       onShown: new EventManager(context, "menus.onShown", fire => {
-        let listener = (event, data) => {
-          fire.sync(data);
+        let listener = (event, menuIds, contextData) => {
+          let info = {
+            menuIds,
+            contexts: Array.from(getMenuContexts(contextData)),
+          };
+
+          // The menus.onShown event is fired before the user has consciously
+          // interacted with an extension, so we require permissions before
+          // exposing sensitive contextual data.
+          let includeSensitiveData =
+            extension.tabManager.hasActiveTabPermission(contextData.tab) ||
+            extension.whiteListedHosts.matches(contextData.inFrame ? contextData.frameUrl : contextData.pageUrl);
+
+          addMenuEventInfo(info, contextData, includeSensitiveData);
+
+          let tab = extension.tabManager.convert(contextData.tab);
+          fire.sync(info, tab);
         };
         extension.on("webext-menu-shown", listener);
         return () => {
           extension.off("webext-menu-shown", listener);
         };
       }).api(),
       onHidden: new EventManager(context, "menus.onHidden", fire => {
         let listener = () => {
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -405,34 +405,70 @@
       {
         "name": "onShown",
         "type": "function",
         "description": "Fired when a menu is shown that contains a menu item that was created by the extension.",
         "parameters": [
           {
             "name": "info",
             "type": "object",
-            "description": "Information about the context of the menu action and the created menu items",
+            "description": "Information about the context of the menu action and the created menu items. For more information about each property, see contextMenusInternal.OnClickData. The following properties are only set if the extension has host permissions for the given context: linkUrl, linkText, srcUrl, pageUrl, frameUrl, selectionText.",
             "properties": {
               "menuIds": {
                 "description": "A list of IDs of the menu items that were shown.",
                 "type": "array",
                 "items": {
                   "choices": [
                     { "type": "integer" },
                     { "type": "string" }
                   ]
                 }
               },
               "contexts": {
                 "description": "A list of all contexts that apply to the menu.",
                 "type": "array",
                 "items": {"$ref": "ContextType"}
+              },
+              "editable": {
+                "type": "boolean"
+              },
+              "mediaType": {
+                "type": "string",
+                "optional": true
+              },
+              "linkUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "linkText": {
+                "type": "string",
+                "optional": true
+              },
+              "srcUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "pageUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "frameUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "selectionText": {
+                "type": "string",
+                "optional": true
               }
             }
+          },
+          {
+            "name": "tab",
+            "$ref": "tabs.Tab",
+            "description": "The details of the tab where the menu was opened."
           }
         ]
       },
       {
         "name": "onHidden",
         "type": "function",
         "description": "Fired when a menu is hidden. This event is only fired if the menu contained a menu item that was created by the extension.",
         "parameters": []
--- a/browser/components/extensions/test/browser/browser_ext_menus_events.js
+++ b/browser/components/extensions/test/browser/browser_ext_menus_events.js
@@ -1,42 +1,56 @@
 /* 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 PAGE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html";
+const PAGE_BASE = PAGE.replace("context.html", "");
+const PAGE_HOST_PATTERN = "http://mochi.test/*";
+
+async function grantOptionalPermission(extension, permissions) {
+  const {GlobalManager} = Cu.import("resource://gre/modules/Extension.jsm", {});
+  const {ExtensionPermissions} = Cu.import("resource://gre/modules/ExtensionPermissions.jsm", {});
+  let ext = GlobalManager.extensionMap.get(extension.id);
+  return ExtensionPermissions.add(ext, permissions);
+}
 
 // Registers a context menu using menus.create(menuCreateParams) and checks
 // whether the menus.onShown and menus.onHidden events are fired as expected.
 // doOpenMenu must open the menu and its returned promise must resolve after the
 // menu is shown. Similarly, doCloseMenu must hide the menu.
 async function testShowHideEvent({menuCreateParams, doOpenMenu, doCloseMenu,
-                                  expectedShownEvent}) {
+                                  expectedShownEvent,
+                                  expectedShownEventWithPermissions = null}) {
   async function background() {
     function awaitMessage(expectedId) {
       return new Promise(resolve => {
         browser.test.log(`Waiting for message: ${expectedId}`);
         browser.test.onMessage.addListener(function listener(id, msg) {
           browser.test.assertEq(expectedId, id, "Expected message");
           browser.test.onMessage.removeListener(listener);
           resolve(msg);
         });
       });
     }
 
     let menuCreateParams = await awaitMessage("create-params");
+    const [tab] = await browser.tabs.query({active: true, currentWindow: true});
 
     let shownEvents = [];
     let hiddenEvents = [];
 
-    browser.menus.onShown.addListener(event => shownEvents.push(event));
+    browser.menus.onShown.addListener((...args) => {
+      shownEvents.push(args[0]);
+      browser.test.assertEq(tab.id, args[1].id, "expected tab");
+      browser.test.assertEq(2, args.length, "expected number of onShown args");
+    });
     browser.menus.onHidden.addListener(event => hiddenEvents.push(event));
 
-    const [tab] = await browser.tabs.query({active: true});
     await browser.pageAction.show(tab.id);
 
     let menuId;
     await new Promise(resolve => {
       menuId = browser.menus.create(menuCreateParams, resolve);
     });
     browser.test.assertEq(0, shownEvents.length, "no onShown before menu");
     browser.test.assertEq(0, hiddenEvents.length, "no onHidden before menu");
@@ -46,26 +60,31 @@ async function testShowHideEvent({menuCr
     browser.test.assertEq(1, shownEvents.length, "expected onShown");
     browser.test.assertEq(0, hiddenEvents.length, "no onHidden before closing");
     browser.test.sendMessage("onShown-event-data", shownEvents[0]);
 
     await awaitMessage("assert-menu-hidden");
     browser.test.assertEq(1, shownEvents.length, "expected no more onShown");
     browser.test.assertEq(1, hiddenEvents.length, "expected onHidden");
     browser.test.sendMessage("onHidden-event-data", hiddenEvents[0]);
+
+    await awaitMessage("optional-menu-shown-with-permissions");
+    browser.test.assertEq(2, shownEvents.length, "expected second onShown");
+    browser.test.sendMessage("onShown-event-data2", shownEvents[1]);
   }
 
   const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
 
   let extension = ExtensionTestUtils.loadExtension({
     background,
     manifest: {
       page_action: {},
       browser_action: {},
       permissions: ["menus"],
+      optional_permissions: [PAGE_HOST_PATTERN],
     },
   });
   await extension.startup();
   extension.sendMessage("create-params", menuCreateParams);
   let menuId = await extension.awaitMessage("menu-registered");
 
   await doOpenMenu(extension);
   extension.sendMessage("assert-menu-shown");
@@ -75,16 +94,30 @@ async function testShowHideEvent({menuCr
   expectedShownEvent.menuIds = [menuId];
   Assert.deepEqual(shownEvent, expectedShownEvent, "expected onShown info");
 
   await doCloseMenu();
   extension.sendMessage("assert-menu-hidden");
   let hiddenEvent = await extension.awaitMessage("onHidden-event-data");
   is(hiddenEvent, undefined, "expected no event data for onHidden event");
 
+  if (expectedShownEventWithPermissions) {
+    expectedShownEventWithPermissions.menuIds = [menuId];
+    await grantOptionalPermission(extension, {
+      permissions: [],
+      origins: [PAGE_HOST_PATTERN],
+    });
+    await doOpenMenu(extension);
+    extension.sendMessage("optional-menu-shown-with-permissions");
+    let shownEvent2 = await extension.awaitMessage("onShown-event-data2");
+    Assert.deepEqual(shownEvent2, expectedShownEventWithPermissions,
+      "expected onShown info when host permissions are enabled");
+    await doCloseMenu();
+  }
+
   await extension.unload();
   await BrowserTestUtils.removeTab(tab);
 }
 
 add_task(async function test_no_show_hide_without_menu_item() {
   let extension = ExtensionTestUtils.loadExtension({
     background() {
       let events = [];
@@ -112,16 +145,18 @@ add_task(async function test_no_show_hid
   // Run another context menu test where onShown/onHidden will fire.
   await testShowHideEvent({
     menuCreateParams: {
       title: "any menu item",
       contexts: ["all"],
     },
     expectedShownEvent: {
       contexts: ["page", "all"],
+      editable: false,
+      frameId: 0,
     },
     async doOpenMenu() {
       await openContextMenu("body");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });
@@ -138,16 +173,22 @@ add_task(async function test_no_show_hid
 add_task(async function test_show_hide_pageAction() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "pageAction item",
       contexts: ["page_action"],
     },
     expectedShownEvent: {
       contexts: ["page_action", "all"],
+      editable: false,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["page_action", "all"],
+      editable: false,
+      pageUrl: PAGE,
     },
     async doOpenMenu(extension) {
       await openActionContextMenu(extension, "page");
     },
     async doCloseMenu() {
       await closeActionContextMenu(null, "page");
     },
   });
@@ -156,16 +197,22 @@ add_task(async function test_show_hide_p
 add_task(async function test_show_hide_browserAction() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "browserAction item",
       contexts: ["browser_action"],
     },
     expectedShownEvent: {
       contexts: ["browser_action", "all"],
+      editable: false,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["browser_action", "all"],
+      editable: false,
+      pageUrl: PAGE,
     },
     async doOpenMenu(extension) {
       await openActionContextMenu(extension, "browser");
     },
     async doCloseMenu() {
       await closeActionContextMenu();
     },
   });
@@ -174,16 +221,22 @@ add_task(async function test_show_hide_b
 add_task(async function test_show_hide_tab() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "tab menu item",
       contexts: ["tab"],
     },
     expectedShownEvent: {
       contexts: ["tab"],
+      editable: false,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["tab"],
+      editable: false,
+      pageUrl: PAGE,
     },
     async doOpenMenu() {
       await openTabContextMenu();
     },
     async doCloseMenu() {
       await closeTabContextMenu();
     },
   });
@@ -192,16 +245,22 @@ add_task(async function test_show_hide_t
 add_task(async function test_show_hide_tools_menu() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "menu item",
       contexts: ["tools_menu"],
     },
     expectedShownEvent: {
       contexts: ["tools_menu"],
+      editable: false,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["tools_menu"],
+      editable: false,
+      pageUrl: PAGE,
     },
     async doOpenMenu() {
       await openToolsMenu();
     },
     async doCloseMenu() {
       await closeToolsMenu();
     },
   });
@@ -210,95 +269,179 @@ add_task(async function test_show_hide_t
 add_task(async function test_show_hide_page() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "page menu item",
       contexts: ["page"],
     },
     expectedShownEvent: {
       contexts: ["page", "all"],
+      editable: false,
+      frameId: 0,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["page", "all"],
+      editable: false,
+      pageUrl: PAGE,
+      frameId: 0,
     },
     async doOpenMenu() {
       await openContextMenu("body");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });
 });
 
 add_task(async function test_show_hide_frame() {
+  // frame info will be determined before opening the menu.
+  let frameId;
   await testShowHideEvent({
     menuCreateParams: {
       title: "subframe menu item",
       contexts: ["frame"],
     },
     expectedShownEvent: {
       contexts: ["frame", "all"],
+      editable: false,
+      get frameId() { return frameId; },
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["frame", "all"],
+      editable: false,
+      get frameId() { return frameId; },
+      pageUrl: PAGE,
+      frameUrl: PAGE_BASE + "context_frame.html",
     },
     async doOpenMenu() {
+      frameId = await ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
+        let {contentWindow} = content.document.getElementById("frame");
+        return WebNavigationFrames.getFrameId(contentWindow);
+      });
       await openContextMenuInFrame("frame");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });
 });
 
 add_task(async function test_show_hide_password() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "password item",
       contexts: ["password"],
     },
     expectedShownEvent: {
       contexts: ["password", "all"],
+      editable: true,
+      frameId: 0,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["password", "all"],
+      editable: true,
+      frameId: 0,
+      pageUrl: PAGE,
     },
     async doOpenMenu() {
       await openContextMenu("#password");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });
 });
 
+add_task(async function test_show_hide_link() {
+  await testShowHideEvent({
+    menuCreateParams: {
+      title: "link item",
+      contexts: ["link"],
+    },
+    expectedShownEvent: {
+      contexts: ["link", "all"],
+      editable: false,
+      frameId: 0,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["link", "all"],
+      editable: false,
+      frameId: 0,
+      linkText: "Some link",
+      linkUrl: PAGE_BASE + "some-link",
+      pageUrl: PAGE,
+    },
+    async doOpenMenu() {
+      await openContextMenu("#link1");
+    },
+    async doCloseMenu() {
+      await closeExtensionContextMenu();
+    },
+  });
+});
+
 add_task(async function test_show_hide_image_link() {
   await testShowHideEvent({
     menuCreateParams: {
       title: "image item",
       contexts: ["image"],
     },
     expectedShownEvent: {
       contexts: ["image", "link", "all"],
+      mediaType: "image",
+      editable: false,
+      frameId: 0,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["image", "link", "all"],
+      mediaType: "image",
+      editable: false,
+      frameId: 0,
+      // Apparently, when a link has no content, its href is used as linkText.
+      linkText: PAGE_BASE + "image-around-some-link",
+      linkUrl: PAGE_BASE + "image-around-some-link",
+      srcUrl: PAGE_BASE + "ctxmenu-image.png",
+      pageUrl: PAGE,
     },
     async doOpenMenu() {
       await openContextMenu("#img-wrapped-in-link");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });
 });
 
 add_task(async function test_show_hide_editable_selection() {
+  let selectionText;
   await testShowHideEvent({
     menuCreateParams: {
       title: "editable item",
       contexts: ["editable"],
     },
     expectedShownEvent: {
       contexts: ["editable", "selection", "all"],
+      editable: true,
+      frameId: 0,
+    },
+    expectedShownEventWithPermissions: {
+      contexts: ["editable", "selection", "all"],
+      editable: true,
+      frameId: 0,
+      pageUrl: PAGE,
+      get selectionText() { return selectionText; },
     },
     async doOpenMenu() {
       // Select lots of text in the test page before opening the menu.
-      await ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
+      selectionText = await ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
         let node = content.document.getElementById("editabletext");
         node.select();
         node.focus();
+        return node.value;
       });
 
       await openContextMenu("#editabletext");
     },
     async doCloseMenu() {
       await closeExtensionContextMenu();
     },
   });