Bug 1310355 Improve resiliency of webrtc add-on hooks r?flo draft
authorAndrew Swan <aswan@mozilla.com>
Fri, 14 Oct 2016 16:04:13 -0700
changeset 454559 efc7ef1394d3774984b5ed1b972e0639bc73d196
parent 444725 8387a4ada9a5c4cab059d8fafe0f8c933e83c149
child 540750 7e9ea8890d452fd03802c2632dfbef0e813c07a4
push id39975
push useraswan@mozilla.com
push dateThu, 29 Dec 2016 18:24:10 +0000
reviewersflo
bugs1310355
milestone53.0a1
Bug 1310355 Improve resiliency of webrtc add-on hooks r?flo MozReview-Commit-ID: 29DN2cmXTtk
browser/base/content/test/webrtc/browser.ini
browser/base/content/test/webrtc/browser_webrtc_hooks.js
browser/modules/webrtcUI.jsm
--- a/browser/base/content/test/webrtc/browser.ini
+++ b/browser/base/content/test/webrtc/browser.ini
@@ -4,8 +4,9 @@ support-files =
   get_user_media_content_script.js
   head.js
 
 [browser_devices_get_user_media.js]
 skip-if = (os == "linux" && debug) # linux: bug 976544
 [browser_devices_get_user_media_anim.js]
 [browser_devices_get_user_media_in_frame.js]
 [browser_devices_get_user_media_tear_off_tab.js]
+[browser_webrtc_hooks.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_webrtc_hooks.js
@@ -0,0 +1,363 @@
+/* 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/. */
+
+Cu.import("resource:///modules/webrtcUI.jsm");
+
+const ORIGIN = "https://example.com";
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+});
+
+function* tryPeerConnection(browser, expectedError = null) {
+  let errtype = yield ContentTask.spawn(browser, null, function*() {
+    let pc = new content.RTCPeerConnection();
+    try {
+      yield pc.createOffer({offerToReceiveAudio: true});
+      return null;
+    } catch (err) {
+      return err.name;
+    }
+  });
+
+  let detail = expectedError ? `createOffer() threw a ${expectedError}`
+      : "createOffer() succeeded";
+  is(errtype, expectedError, detail);
+}
+
+// Helper for tests that use the peer-request-allowed and -blocked events.
+// A test that expects some of those events does the following:
+//  - call Events.on() before the test to setup event handlers
+//  - call Events.expect(name) after a specific event is expected to have
+//    occured.  This will fail if the event didn't occur, and will return
+//    the details passed to the handler for furhter checking.
+//  - call Events.off() at the end of the test to clean up.  At this point, if
+//    any events were triggered that the test did not expect, the test fails.
+const Events = {
+  events: ["peer-request-allowed", "peer-request-blocked"],
+  details: new Map(),
+  handlers: new Map(),
+  on() {
+    for (let event of this.events) {
+      let handler = data => {
+        if (this.details.has(event)) {
+          ok(false, `Got multiple ${event} events`);
+        }
+        this.details.set(event, data);
+      };
+      webrtcUI.on(event, handler);
+      this.handlers.set(event, handler);
+    }
+  },
+  expect(event) {
+    let result = this.details.get(event);
+    isnot(result, undefined, `${event} event was triggered`);
+    this.details.delete(event);
+
+    // All events should have a good origin
+    is(result.origin, ORIGIN, `${event} event has correct origin`);
+
+    return result;
+  },
+  off() {
+    for (let event of this.events) {
+      webrtcUI.off(event, this.handlers.get(event));
+      this.handlers.delete(event);
+    }
+    for (let [event, ] of this.details) {
+      ok(false, `Got unexpected event ${event}`);
+    }
+  },
+};
+
+var gTests = [
+  {
+    desc: "Basic peer-request-allowed event",
+    run: function* testPeerRequestEvent(browser) {
+      Events.on();
+
+      yield tryPeerConnection(browser);
+
+      let details = Events.expect("peer-request-allowed");
+      isnot(details.callID, undefined, "peer-request-allowed event includes callID");
+      isnot(details.windowID, undefined, "peer-request-allowed event includes windowID");
+
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Immediate peer connection blocker can allow",
+    run: function* testBlocker(browser) {
+      Events.on();
+
+      let blockerCalled = false;
+      let blocker = params => {
+        is(params.origin, ORIGIN, "Peer connection blocker origin parameter is correct");
+        blockerCalled = true;
+        return "allow";
+      };
+
+      webrtcUI.addPeerConnectionBlocker(blocker);
+
+      yield tryPeerConnection(browser);
+      is(blockerCalled, true, "Blocker was called");
+      Events.expect("peer-request-allowed");
+
+      webrtcUI.removePeerConnectionBlocker(blocker);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Deferred peer connection blocker can allow",
+    run: function* testDeferredBlocker(browser) {
+      Events.on();
+
+      let blocker = params => Promise.resolve("allow");
+      webrtcUI.addPeerConnectionBlocker(blocker);
+
+      yield tryPeerConnection(browser);
+      Events.expect("peer-request-allowed");
+
+      webrtcUI.removePeerConnectionBlocker(blocker);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Immediate peer connection blocker can deny",
+    run: function* testBlockerDeny(browser) {
+      Events.on();
+
+      let blocker = params => "deny";
+      webrtcUI.addPeerConnectionBlocker(blocker);
+
+      yield tryPeerConnection(browser, "NotAllowedError");
+
+      Events.expect("peer-request-blocked");
+
+      webrtcUI.removePeerConnectionBlocker(blocker);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Multiple blockers work (both allow)",
+    run: function* testMultipleAllowBlockers(browser) {
+      Events.on();
+
+      let blocker1Called = false, blocker1 = params => {
+        blocker1Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker1);
+
+      let blocker2Called = false, blocker2 = params => {
+        blocker2Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker2);
+
+      yield tryPeerConnection(browser);
+
+      Events.expect("peer-request-allowed");
+      ok(blocker1Called, "First blocker was called");
+      ok(blocker2Called, "Second blocker was called");
+
+      webrtcUI.removePeerConnectionBlocker(blocker1);
+      webrtcUI.removePeerConnectionBlocker(blocker2);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Multiple blockers work (allow then deny)",
+    run: function* testAllowDenyBlockers(browser) {
+      Events.on();
+
+      let blocker1Called = false, blocker1 = params => {
+        blocker1Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker1);
+
+      let blocker2Called = false, blocker2 = params => {
+        blocker2Called = true;
+        return "deny";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker2);
+
+      yield tryPeerConnection(browser, "NotAllowedError");
+
+      Events.expect("peer-request-blocked");
+      ok(blocker1Called, "First blocker was called");
+      ok(blocker2Called, "Second blocker was called");
+
+      webrtcUI.removePeerConnectionBlocker(blocker1);
+      webrtcUI.removePeerConnectionBlocker(blocker2);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Multiple blockers work (deny first)",
+    run: function* testDenyAllowBlockers(browser) {
+      Events.on();
+
+      let blocker1Called = false, blocker1 = params => {
+        blocker1Called = true;
+        return "deny";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker1);
+
+      let blocker2Called = false, blocker2 = params => {
+        blocker2Called = true;
+        return "allow";
+      }
+      webrtcUI.addPeerConnectionBlocker(blocker2);
+
+      yield tryPeerConnection(browser, "NotAllowedError");
+
+      Events.expect("peer-request-blocked");
+      ok(blocker1Called, "First blocker was called");
+      ok(!blocker2Called, "Peer connection blocker after a deny is not invoked");
+
+      webrtcUI.removePeerConnectionBlocker(blocker1);
+      webrtcUI.removePeerConnectionBlocker(blocker2);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Blockers may be removed",
+    run: function* testRemoveBlocker(browser) {
+      Events.on();
+
+      let blocker1Called = false, blocker1 = params => {
+        blocker1Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker1);
+
+      let blocker2Called = false, blocker2 = params => {
+        blocker2Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker2);
+      webrtcUI.removePeerConnectionBlocker(blocker1);
+
+      yield tryPeerConnection(browser);
+
+      Events.expect("peer-request-allowed");
+
+      ok(!blocker1Called, "Removed peer connection blocker is not invoked");
+      ok(blocker2Called, "Second peer connection blocker was invoked");
+
+      webrtcUI.removePeerConnectionBlocker(blocker2);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Blocker that throws is ignored",
+    run: function* testBlockerThrows(browser) {
+      Events.on();
+      let blocker1Called = false, blocker1 = params => {
+        blocker1Called = true;
+        throw new Error("kaboom");
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker1);
+
+      let blocker2Called = false, blocker2 = params => {
+        blocker2Called = true;
+        return "allow";
+      };
+      webrtcUI.addPeerConnectionBlocker(blocker2);
+
+      yield tryPeerConnection(browser);
+
+      Events.expect("peer-request-allowed");
+      ok(blocker1Called, "First blocker was invoked");
+      ok(blocker2Called, "Second blocker was invoked");
+
+      webrtcUI.removePeerConnectionBlocker(blocker1);
+      webrtcUI.removePeerConnectionBlocker(blocker2);
+      Events.off();
+    },
+  },
+
+  {
+    desc: "Cancel peer request",
+    run: function* testBlockerCancel(browser) {
+      let blocker, blockerPromise = new Promise(resolve => {
+        blocker = params => {
+          resolve();
+          // defer indefinitely
+          return new Promise(innerResolve => {});
+        };
+      });
+      webrtcUI.addPeerConnectionBlocker(blocker);
+
+      yield ContentTask.spawn(browser, null, function*() {
+        (new content.RTCPeerConnection()).createOffer({offerToReceiveAudio: true});
+      });
+
+      yield blockerPromise;
+
+      let eventPromise = new Promise(resolve => {
+        webrtcUI.on("peer-request-cancel", function listener(details) {
+          resolve(details);
+          webrtcUI.off("peer-request-cancel", listener);
+        });
+      });
+
+      yield ContentTask.spawn(browser, null, function*() {
+        content.location.reload();
+      });
+
+      let details = yield eventPromise;
+      isnot(details.callID, undefined, "peer-request-cancel event includes callID");
+      is(details.origin, ORIGIN, "peer-request-cancel event has correct origin");
+
+      webrtcUI.removePeerConnectionBlocker(blocker);
+    },
+  },
+];
+
+function test() {
+  waitForExplicitFinish();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  let browser = tab.linkedBrowser;
+
+  browser.addEventListener("load", function onload() {
+    browser.removeEventListener("load", onload, true);
+
+    is(PopupNotifications._currentNotifications.length, 0,
+       "should start the test without any prior popup notification");
+    ok(gIdentityHandler._identityPopup.hidden,
+       "should start the test with the control center hidden");
+
+    Task.spawn(function* () {
+      yield SpecialPowers.pushPrefEnv({"set": [[PREF_PERMISSION_FAKE, true]]});
+
+      for (let testCase of gTests) {
+        info(testCase.desc);
+        yield testCase.run(browser);
+
+        // Make sure the test cleaned up after itself.
+        is(webrtcUI.peerConnectionBlockers.size, 0, "Peer connection blockers list is empty");
+      }
+    }).then(finish, ex => {
+     Cu.reportError(ex);
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath);
+  rootDir = rootDir.replace("chrome://mochitests/content", ORIGIN);
+  content.location = rootDir + "get_user_media.html";
+}
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -5,29 +5,35 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["webrtcUI"];
 
 const Cu = Components.utils;
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 
+Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                   "resource://gre/modules/PluralForm.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
   return Services.strings.createBundle("chrome://branding/locale/brand.properties");
 });
 
 this.webrtcUI = {
+  peerConnectionBlockers: new Set(),
+  emitter: new EventEmitter(),
+
   init: function() {
     Services.obs.addObserver(maybeAddMenuIndicator, "browser-delayed-startup-finished", false);
 
     let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
                  .getService(Ci.nsIMessageBroadcaster);
     ppmm.addMessageListener("webrtc:UpdatingIndicators", this);
     ppmm.addMessageListener("webrtc:UpdateGlobalIndicators", this);
     ppmm.addMessageListener("child-process-shutdown", this);
@@ -160,59 +166,103 @@ this.webrtcUI = {
   },
 
   updateWarningLabel: function(aMenuList) {
     let type = aMenuList.selectedItem.getAttribute("devicetype");
     let document = aMenuList.ownerDocument;
     document.getElementById("webRTC-all-windows-shared").hidden = type != "Screen";
   },
 
+  // Add-ons can override stock permission behavior by doing:
+  //
+  //   webrtcUI.addPeerConnectionBlocker(function(aParams) {
+  //     // new permission checking logic
+  //   }));
+  //
+  // The blocking function receives an object with origin, callID, and windowID
+  // parameters.  If it returns the string "deny" or a Promise that resolves
+  // to "deny", the connection is immediately blocked.  With any other return
+  // value (though the string "allow" is suggested for consistency), control
+  // is passed to other registered blockers.  If no registered blockers block
+  // the connection (or of course if there are no registered blockers), then
+  // the connection is allowed.
+  //
+  // Add-ons may also use webrtcUI.on/off to listen to events without
+  // blocking anything:
+  //   peer-request-allowed is emitted when a new peer connection is
+  //                        established (and not blocked).
+  //   peer-request-blocked is emitted when a peer connection request is
+  //                        blocked by some blocking connection handler.
+  //   peer-request-cancel is emitted when a peer-request connection request
+  //                       is canceled.  (This would typically be used in
+  //                       conjunction with a blocking handler to cancel
+  //                       a user prompt or other work done by the handler)
+  addPeerConnectionBlocker: function(aCallback) {
+    this.peerConnectionBlockers.add(aCallback);
+  },
+
+  removePeerConnectionBlocker: function(aCallback) {
+    this.peerConnectionBlockers.delete(aCallback);
+  },
+
+  on: function(...args) {
+    return this.emitter.on(...args);
+  },
+
+  off: function(...args) {
+    return this.emitter.off(...args);
+  },
+
   receiveMessage: function(aMessage) {
     switch (aMessage.name) {
 
-      // Add-ons can override stock permission behavior by doing:
-      //
-      //   var stockReceiveMessage = webrtcUI.receiveMessage;
-      //
-      //   webrtcUI.receiveMessage = function(aMessage) {
-      //     switch (aMessage.name) {
-      //      case "rtcpeer:Request": {
-      //        // new code.
-      //        break;
-      //      ...
-      //      default:
-      //        return stockReceiveMessage.call(this, aMessage);
-      //
-      // Intercepting gUM and peerConnection requests should let an add-on
-      // limit PeerConnection activity with automatic rules and/or prompts
-      // in a sensible manner that avoids double-prompting in typical
-      // gUM+PeerConnection scenarios. For example:
-      //
-      //   State                                    Sample Action
-      //   --------------------------------------------------------------
-      //   No IP leaked yet + No gUM granted        Warn user
-      //   No IP leaked yet + gUM granted           Avoid extra dialog
-      //   No IP leaked yet + gUM request pending.  Delay until gUM grant
-      //   IP already leaked                        Too late to warn
+      case "rtcpeer:Request": {
+        let params = Object.freeze(Object.assign({
+          origin: aMessage.target.contentPrincipal.origin
+        }, aMessage.data));
+
+        let blockers = Array.from(this.peerConnectionBlockers);
 
-      case "rtcpeer:Request": {
-        // Always allow. This code-point exists for add-ons to override.
-        let { callID, windowID } = aMessage.data;
-        // Also available: isSecure, innerWindowID. For contentWindow:
-        //
-        //   let contentWindow = Services.wm.getOuterWindowWithId(windowID);
+        Task.spawn(function*() {
+          for (let blocker of blockers) {
+            try {
+              let result = yield blocker(params);
+              if (result == "deny") {
+                return false;
+              }
+            } catch (err) {
+              Cu.reportError(`error in PeerConnection blocker: ${err.message}`);
+            }
+          }
+          return true;
+        }).then(decision => {
+          let message;
+          if (decision) {
+            this.emitter.emit("peer-request-allowed", params);
+            message = "rtcpeer:Allow";
+          } else {
+            this.emitter.emit("peer-request-blocked", params);
+            message = "rtcpeer:Deny";
+          }
 
-        let mm = aMessage.target.messageManager;
-        mm.sendAsyncMessage("rtcpeer:Allow",
-                            { callID: callID, windowID: windowID });
+          aMessage.target.messageManager.sendAsyncMessage(message, {
+            callID: params.callID,
+            windowID: params.windowID,
+          });
+        });
         break;
       }
-      case "rtcpeer:CancelRequest":
-        // No data to release. This code-point exists for add-ons to override.
+      case "rtcpeer:CancelRequest": {
+        let params = Object.freeze({
+          origin: aMessage.target.contentPrincipal.origin,
+          callID: aMessage.data
+        });
+        this.emitter.emit("peer-request-cancel", params);
         break;
+      }
       case "webrtc:Request":
         prompt(aMessage.target, aMessage.data);
         break;
       case "webrtc:CancelRequest":
         removePrompt(aMessage.target, aMessage.data);
         break;
       case "webrtc:UpdatingIndicators":
         webrtcUI._streams = [];