Bug 1206232 - Add temporary permission states to SitePermissions.jsm. r=paolo,jdm draft
authorJohann Hofmann <jhofmann@mozilla.com>
Thu, 22 Sep 2016 23:09:30 +0200
changeset 461319 a836d3535f4596d1cc7bc633612c83fdc9f4455a
parent 459680 048240a074e841c425a4da4707cf8e353074ec1d
child 542295 6f0778c48eb44ca55399c8f1884cd1b7f51262bc
push id41648
push userbmo:jhofmann@mozilla.com
push dateMon, 16 Jan 2017 12:48:40 +0000
reviewerspaolo, jdm
bugs1206232
milestone53.0a1
Bug 1206232 - Add temporary permission states to SitePermissions.jsm. r=paolo,jdm MozReview-Commit-ID: zQVeEHhIax
browser/app/profile/firefox.js
browser/base/content/browser.js
browser/base/content/pageinfo/permissions.js
browser/base/content/tabbrowser.xml
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_permissions.js
browser/base/content/test/general/browser_temporary_permissions.js
browser/base/content/test/general/browser_temporary_permissions_navigation.js
browser/base/content/test/general/permissions.html
browser/base/content/test/general/temporary_permissions_subframe.html
browser/base/content/test/webrtc/browser_devices_get_user_media.js
browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js
browser/base/content/test/webrtc/head.js
browser/locales/en-US/chrome/browser/sitePermissions.properties
browser/modules/PermissionUI.jsm
browser/modules/SitePermissions.jsm
browser/modules/test/browser.ini
browser/modules/test/browser_PermissionUI.js
browser/modules/test/browser_SitePermissions.js
browser/modules/test/browser_SitePermissions_combinations.js
browser/modules/test/browser_SitePermissions_expiry.js
browser/modules/test/browser_SitePermissions_tab_urls.js
browser/modules/test/browser_UsageTelemetry.js
browser/modules/test/xpcshell/test_SitePermissions.js
browser/modules/webrtcUI.jsm
browser/tools/mozscreenshots/mozscreenshots/extension/configurations/ControlCenter.jsm
extensions/cookie/nsPermissionManager.cpp
netwerk/base/nsIPermissionManager.idl
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -528,16 +528,19 @@ pref("privacy.sanitize.timeSpan", 1);
 pref("privacy.sanitize.sanitizeOnShutdown", false);
 
 pref("privacy.sanitize.migrateFx3Prefs",    false);
 
 pref("privacy.panicButton.enabled",         true);
 
 pref("privacy.firstparty.isolate",          false);
 
+// Time until temporary permissions expire, in ms
+pref("privacy.temporary_permission_expire_time_ms",  3600000);
+
 pref("network.proxy.share_proxy_settings",  false); // use the same proxy settings for all protocols
 
 // simple gestures support
 pref("browser.gesture.swipe.left", "Browser:BackOrBackDuplicate");
 pref("browser.gesture.swipe.right", "Browser:ForwardOrForwardDuplicate");
 pref("browser.gesture.swipe.up", "cmd_scrollTop");
 pref("browser.gesture.swipe.down", "cmd_scrollBottom");
 #ifdef XP_MACOSX
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1122,16 +1122,20 @@ var gBrowserInit = {
       // Reset the zoom for the tabcrashed page.
       ZoomManager.setZoomForBrowser(browser, 1);
     }, false, true);
 
     gBrowser.addEventListener("InsecureLoginFormsStateChange", function() {
       gIdentityHandler.refreshForInsecureLoginForms();
     });
 
+    gBrowser.addEventListener("PermissionStateChange", function() {
+      gIdentityHandler.refreshIdentityBlock();
+    });
+
     let uriToLoad = this._getUriToLoad();
     if (uriToLoad && uriToLoad != "about:blank") {
       if (uriToLoad instanceof Ci.nsIArray) {
         let count = uriToLoad.length;
         let specs = [];
         for (let i = 0; i < count; i++) {
           let urisstring = uriToLoad.queryElementAt(i, Ci.nsISupportsString);
           specs.push(urisstring.data);
@@ -3220,16 +3224,20 @@ function BrowserReloadWithFlags(reloadFl
   if (gBrowser.updateBrowserRemotenessByURL(gBrowser.selectedBrowser, url)) {
     // If the remoteness has changed, the new browser doesn't have any
     // information of what was loaded before, so we need to load the previous
     // URL again.
     gBrowser.loadURIWithFlags(url, reloadFlags);
     return;
   }
 
+  // Reset temporary permissions on the current tab. This is done here
+  // because we only want to reset permissions on user reload.
+  SitePermissions.clearTemporaryPermissions(gBrowser.selectedBrowser);
+
   let windowUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils);
 
   gBrowser.selectedBrowser
           .messageManager
           .sendAsyncMessage("Browser:Reload",
                             { flags: reloadFlags,
                               handlingUserInput: windowUtils.isHandlingUserInput });
@@ -3322,17 +3330,17 @@ function getPEMString(cert) {
 
 var PrintPreviewListener = {
   _printPreviewTab: null,
   _tabBeforePrintPreview: null,
   _simplifyPageTab: null,
 
   getPrintPreviewBrowser() {
     if (!this._printPreviewTab) {
-      let browser = gBrowser.selectedTab.linkedBrowser;
+      let browser = gBrowser.selectedBrowser;
       let preferredRemoteType = browser.remoteType;
       this._tabBeforePrintPreview = gBrowser.selectedTab;
       this._printPreviewTab = gBrowser.loadOneTab("about:blank",
                                                   { inBackground: false,
                                                     preferredRemoteType,
                                                     relatedBrowser: browser });
       gBrowser.selectedTab = this._printPreviewTab;
     }
@@ -4638,18 +4646,22 @@ var XULBrowserWindow = {
   // aRequest will be null always in case 2 and 3, and sometimes in case 1 (for
   // instance, there won't be a request when STATE_BLOCKED_TRACKING_CONTENT is observed).
   onSecurityChange(aWebProgress, aRequest, aState, aIsSimulated) {
     // Don't need to do anything if the data we use to update the UI hasn't
     // changed
     let uri = gBrowser.currentURI;
     let spec = uri.spec;
     if (this._state == aState &&
-        this._lastLocation == spec)
+        this._lastLocation == spec) {
+      // Switching to a tab of the same URL doesn't change most security
+      // information, but tab specific permissions may be different.
+      gIdentityHandler.refreshIdentityBlock();
       return;
+    }
     this._state = aState;
     this._lastLocation = spec;
 
     if (typeof(aIsSimulated) != "boolean" && typeof(aIsSimulated) != "undefined") {
       throw "onSecurityChange: aIsSimulated receieved an unexpected type";
     }
 
     // Make sure the "https" part of the URL is striked out or not,
@@ -7016,26 +7028,26 @@ var gIdentityHandler = {
     for (let icon of Object.values(permissionAnchors)) {
       icon.removeAttribute("showing");
     }
 
     // keeps track if we should show an indicator that there are active permissions
     let hasGrantedPermissions = false;
 
     // show permission icons
-    for (let permission of SitePermissions.getAllByURI(this._uri)) {
-      if (permission.state === SitePermissions.BLOCK) {
+    let permissions = SitePermissions.getAllForBrowser(gBrowser.selectedBrowser);
+    for (let permission of permissions) {
+      if (permission.state == SitePermissions.BLOCK) {
 
         let icon = permissionAnchors[permission.id];
         if (icon) {
           icon.setAttribute("showing", "true");
         }
 
-      } else if (permission.state === SitePermissions.ALLOW ||
-                 permission.state === SitePermissions.SESSION) {
+      } else if (permission.state != SitePermissions.UNKNOWN) {
         hasGrantedPermissions = true;
       }
     }
 
     if (hasGrantedPermissions) {
       this._identityBox.classList.add("grantedPermissions");
     }
 
@@ -7360,19 +7372,19 @@ var gIdentityHandler = {
       this._permissionEmptyHint.removeAttribute("hidden");
     }
   },
 
   updateSitePermissions() {
     while (this._permissionList.hasChildNodes())
       this._permissionList.removeChild(this._permissionList.lastChild);
 
-    let uri = gBrowser.currentURI;
-
-    let permissions = SitePermissions.getPermissionDetailsByURI(uri);
+    let permissions =
+      SitePermissions.getAllPermissionDetailsForBrowser(gBrowser.selectedBrowser);
+
     if (this._sharingState) {
       // If WebRTC device or screen permissions are in use, we need to find
       // the associated permission item to set the inUse field to true.
       for (let id of ["camera", "microphone", "screen"]) {
         if (this._sharingState[id]) {
           let found = false;
           for (let permission of permissions) {
             if (permission.id != id)
@@ -7380,17 +7392,18 @@ var gIdentityHandler = {
             found = true;
             permission.inUse = true;
             break;
           }
           if (!found) {
             // If the permission item we were looking for doesn't exist,
             // the user has temporarily allowed sharing and we need to add
             // an item in the permissions array to reflect this.
-            let permission = SitePermissions.getPermissionItem(id);
+            let permission =
+              SitePermissions.getPermissionDetails(id, SitePermissions.SCOPE_REQUEST);
             permission.inUse = true;
             permissions.push(permission);
           }
         }
       }
     }
     for (let permission of permissions) {
       let item = this._createPermissionItem(permission);
@@ -7436,67 +7449,83 @@ var gIdentityHandler = {
     let nameLabel = document.createElement("label");
     nameLabel.setAttribute("flex", "1");
     nameLabel.setAttribute("class", "identity-popup-permission-label");
     nameLabel.textContent = SitePermissions.getPermissionLabel(aPermission.id);
 
     let stateLabel = document.createElement("label");
     stateLabel.setAttribute("flex", "1");
     stateLabel.setAttribute("class", "identity-popup-permission-state-label");
-    stateLabel.textContent = SitePermissions.getStateLabel(
-      aPermission.id, aPermission.state, aPermission.inUse || false);
+    let {state, scope} = aPermission;
+    // If the user did not permanently allow this device but it is currently
+    // used, set the variables to display a "temporarily allowed" info.
+    if (state != SitePermissions.ALLOW && aPermission.inUse) {
+      state = SitePermissions.ALLOW;
+      scope = SitePermissions.SCOPE_REQUEST;
+    }
+    stateLabel.textContent = SitePermissions.getStateLabel(state, scope);
 
     let button = document.createElement("button");
     button.setAttribute("class", "identity-popup-permission-remove-button");
     let tooltiptext = gNavigatorBundle.getString("permissions.remove.tooltip");
     button.setAttribute("tooltiptext", tooltiptext);
     button.addEventListener("command", () => {
+	  let browser = gBrowser.selectedBrowser;
       // Only resize the window if the reload hint was previously hidden.
       this._handleHeightChange(() => this._permissionList.removeChild(container),
                                this._permissionReloadHint.hasAttribute("hidden"));
       if (aPermission.inUse &&
           ["camera", "microphone", "screen"].includes(aPermission.id)) {
         let windowId = this._sharingState.windowId;
         if (aPermission.id == "screen") {
           windowId = "screen:" + windowId;
         } else {
           // If we set persistent permissions or the sharing has
           // started due to existing persistent permissions, we need
           // to handle removing these even for frames with different hostnames.
-          let uris = gBrowser.selectedBrowser._devicePermissionURIs || [];
+          let uris = browser._devicePermissionURIs || [];
           for (let uri of uris) {
             // It's not possible to stop sharing one of camera/microphone
             // without the other.
             for (let id of ["camera", "microphone"]) {
-              if (this._sharingState[id] &&
-                  SitePermissions.get(uri, id) == SitePermissions.ALLOW)
-                SitePermissions.remove(uri, id);
+              if (this._sharingState[id]) {
+                let perm = SitePermissions.get(uri, id);
+                if (perm.state == SitePermissions.ALLOW &&
+                    perm.scope == SitePermissions.SCOPE_PERSISTENT) {
+                  SitePermissions.remove(uri, id);
+                }
+              }
             }
           }
         }
-        let mm = gBrowser.selectedBrowser.messageManager;
-        mm.sendAsyncMessage("webrtc:StopSharing", windowId);
+        browser.messageManager.sendAsyncMessage("webrtc:StopSharing", windowId);
       }
-      SitePermissions.remove(gBrowser.currentURI, aPermission.id);
+      SitePermissions.remove(gBrowser.currentURI, aPermission.id, browser);
 
       this._permissionReloadHint.removeAttribute("hidden");
 
       // Set telemetry values for clearing a permission
       let histogram = Services.telemetry.getKeyedHistogramById("WEB_PERMISSION_CLEARED");
 
       let permissionType = 0;
-      if (aPermission.state == SitePermissions.ALLOW) {
+      if (aPermission.state == SitePermissions.ALLOW &&
+          aPermission.scope == SitePermissions.SCOPE_PERSISTENT) {
         // 1 : clear permanently allowed permission
         permissionType = 1;
-      } else if (aPermission.state == SitePermissions.BLOCK) {
+      } else if (aPermission.state == SitePermissions.BLOCK &&
+                 aPermission.scope == SitePermissions.SCOPE_PERSISTENT) {
         // 2 : clear permanently blocked permission
         permissionType = 2;
+      } else if (aPermission.state == SitePermissions.ALLOW) {
+        // 3 : clear temporary allowed permission
+        permissionType = 3;
+      } else if (aPermission.state == SitePermissions.BLOCK) {
+        // 4 : clear temporary blocked permission
+        permissionType = 4;
       }
-      // 3 : TODO clear temporary allowed permission
-      // 4 : TODO clear temporary blocked permission
 
       histogram.add("(all)", permissionType);
       histogram.add(aPermission.id, permissionType);
     });
 
     container.appendChild(img);
     container.appendChild(nameLabel);
     container.appendChild(stateLabel);
--- a/browser/base/content/pageinfo/permissions.js
+++ b/browser/base/content/pageinfo/permissions.js
@@ -68,27 +68,27 @@ function initRow(aPartId) {
     initPluginsRow();
     return;
   }
 
   createRow(aPartId);
 
   var checkbox = document.getElementById(aPartId + "Def");
   var command  = document.getElementById("cmd_" + aPartId + "Toggle");
-  var perm = SitePermissions.get(gPermURI, aPartId);
+  var {state} = SitePermissions.get(gPermURI, aPartId);
 
-  if (perm) {
+  if (state != SitePermissions.UNKNOWN) {
     checkbox.checked = false;
     command.removeAttribute("disabled");
   } else {
     checkbox.checked = true;
     command.setAttribute("disabled", "true");
-    perm = SitePermissions.getDefault(aPartId);
+    state = SitePermissions.getDefault(aPartId);
   }
-  setRadioState(aPartId, perm);
+  setRadioState(aPartId, state);
 
   if (aPartId == "indexedDB") {
     initIndexedDBRow();
   }
 }
 
 function createRow(aPartId) {
   let rowId = "perm-" + aPartId + "-row";
@@ -130,17 +130,17 @@ function createRow(aPartId) {
   controls.appendChild(spacer);
 
   let radiogroup = document.createElement("radiogroup");
   radiogroup.setAttribute("id", radiogroupId);
   radiogroup.setAttribute("orient", "horizontal");
   for (let state of SitePermissions.getAvailableStates(aPartId)) {
     let radio = document.createElement("radio");
     radio.setAttribute("id", aPartId + "#" + state);
-    radio.setAttribute("label", SitePermissions.getStateLabel(aPartId, state));
+    radio.setAttribute("label", SitePermissions.getStateLabel(state));
     radio.setAttribute("command", commandId);
     radiogroup.appendChild(radio);
   }
   controls.appendChild(radiogroup);
 
   row.appendChild(controls);
 
   document.getElementById("permList").appendChild(row);
@@ -309,12 +309,12 @@ function initPluginsRow() {
 }
 
 function setPluginsRadioState() {
   let box = document.getElementById("perm-plugins-row");
   for (let permissionEntry of box.childNodes) {
     if (permissionEntry.hasAttribute("permString")) {
       let permString = permissionEntry.getAttribute("permString");
       let permission = SitePermissions.get(gPermURI, permString);
-      setRadioState(permString, permission);
+      setRadioState(permString, permission.state);
     }
   }
 }
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -2858,16 +2858,18 @@
             }
             if (aOtherTab.hasAttribute("sharing")) {
               aOurTab.setAttribute("sharing", aOtherTab.getAttribute("sharing"));
               modifiedAttrs.push("sharing");
               aOurTab._sharingState = aOtherTab._sharingState;
               webrtcUI.swapBrowserForNotification(otherBrowser, ourBrowser);
             }
 
+            SitePermissions.copyTemporaryPermissions(otherBrowser, ourBrowser);
+
             // If the other tab is pending (i.e. has not been restored, yet)
             // then do not switch docShells but retrieve the other tab's state
             // and apply it to our tab.
             if (isPending) {
               SessionStore.setTabState(aOurTab, SessionStore.getTabState(aOtherTab));
 
               // Make sure to unregister any open URIs.
               this._swapRegisteredOpenURIs(ourBrowser, otherBrowser);
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -415,16 +415,21 @@ skip-if = os == "linux" || os == "mac" #
 skip-if = (os == "mac" && !e10s) # Bug 1237713 - OSX eats keypresses for some reason
 [browser_tabopen_reflows.js]
 [browser_tabs_close_beforeunload.js]
 support-files =
   close_beforeunload_opens_second_tab.html
   close_beforeunload.html
 [browser_tabs_isActive.js]
 [browser_tabs_owner.js]
+[browser_temporary_permissions.js]
+support-files =
+  permissions.html
+  temporary_permissions_subframe.html
+[browser_temporary_permissions_navigation.js]
 [browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js]
 run-if = e10s
 [browser_trackingUI_1.js]
 tags = trackingprotection
 support-files =
   trackingPage.html
   benignPage.html
 [browser_trackingUI_2.js]
--- a/browser/base/content/test/general/browser_permissions.js
+++ b/browser/base/content/test/general/browser_permissions.js
@@ -85,17 +85,17 @@ add_task(function* testIdentityIcon() {
   ok(!gIdentityHandler._identityBox.classList.contains("grantedPermissions"),
     "identity-box doesn't signal granted permissions");
 
   SitePermissions.set(gBrowser.currentURI, "camera", SitePermissions.BLOCK);
 
   ok(!gIdentityHandler._identityBox.classList.contains("grantedPermissions"),
     "identity-box doesn't signal granted permissions");
 
-  SitePermissions.set(gBrowser.currentURI, "cookie", SitePermissions.SESSION);
+  SitePermissions.set(gBrowser.currentURI, "cookie", SitePermissions.ALLOW_COOKIES_FOR_SESSION);
 
   ok(gIdentityHandler._identityBox.classList.contains("grantedPermissions"),
     "identity-box signals granted permissions");
 
   SitePermissions.remove(gBrowser.currentURI, "geo");
   SitePermissions.remove(gBrowser.currentURI, "camera");
   SitePermissions.remove(gBrowser.currentURI, "cookie");
 });
@@ -174,29 +174,23 @@ add_task(function* testPermissionHints()
 
 add_task(function* testPermissionIcons() {
   let {gIdentityHandler} = gBrowser.ownerGlobal;
   let tab = gBrowser.selectedTab = gBrowser.addTab();
   yield promiseTabLoadEvent(tab, PERMISSIONS_PAGE);
 
   SitePermissions.set(gBrowser.currentURI, "camera", SitePermissions.ALLOW);
   SitePermissions.set(gBrowser.currentURI, "geo", SitePermissions.BLOCK);
-  SitePermissions.set(gBrowser.currentURI, "microphone", SitePermissions.SESSION);
 
   let geoIcon = gIdentityHandler._identityBox
     .querySelector(".blocked-permission-icon[data-permission-id='geo']");
   ok(geoIcon.hasAttribute("showing"), "blocked permission icon is shown");
 
   let cameraIcon = gIdentityHandler._identityBox
     .querySelector(".blocked-permission-icon[data-permission-id='camera']");
   ok(!cameraIcon.hasAttribute("showing"),
     "allowed permission icon is not shown");
 
-  let microphoneIcon  = gIdentityHandler._identityBox
-    .querySelector(".blocked-permission-icon[data-permission-id='microphone']");
-  ok(!microphoneIcon.hasAttribute("showing"),
-    "allowed permission icon is not shown");
-
   SitePermissions.remove(gBrowser.currentURI, "geo");
 
   ok(!geoIcon.hasAttribute("showing"),
     "blocked permission icon is not shown after reset");
 });
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_temporary_permissions.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+Cu.import("resource:///modules/E10SUtils.jsm");
+
+const SUBFRAME_PAGE = "https://example.com/browser/browser/base/content/test/general/temporary_permissions_subframe.html";
+
+// Test that setting temp permissions triggers a change in the identity block.
+add_task(function* testTempPermissionChangeEvents() {
+  let uri = NetUtil.newURI("https://example.com");
+  let id = "geo";
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function*(browser) {
+    SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    let geoIcon = document.querySelector(".blocked-permission-icon[data-permission-id=geo]");
+
+    Assert.notEqual(geoIcon.boxObject.width, 0, "geo anchor should be visible");
+
+    SitePermissions.remove(uri, id, browser);
+
+    Assert.equal(geoIcon.boxObject.width, 0, "geo anchor should not be visible");
+  });
+});
+
+// Test that temp permissions are persisted through moving tabs to new windows.
+add_task(function* testTempPermissionOnTabMove() {
+  let uri = NetUtil.newURI("https://example.com");
+  let id = "geo";
+
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, uri.spec);
+
+  SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, tab.linkedBrowser);
+
+  Assert.deepEqual(SitePermissions.get(uri, id, tab.linkedBrowser), {
+    state: SitePermissions.BLOCK,
+    scope: SitePermissions.SCOPE_TEMPORARY,
+  });
+
+  let promiseWin = BrowserTestUtils.waitForNewWindow();
+  gBrowser.replaceTabWithWindow(tab);
+  let win = yield promiseWin;
+  tab = win.gBrowser.selectedTab;
+
+  Assert.deepEqual(SitePermissions.get(uri, id, tab.linkedBrowser), {
+    state: SitePermissions.BLOCK,
+    scope: SitePermissions.SCOPE_TEMPORARY,
+  });
+
+  SitePermissions.remove(uri, id, tab.linkedBrowser);
+  yield BrowserTestUtils.closeWindow(win);
+});
+
+// Test that temp permissions don't affect other tabs of the same URI.
+add_task(function* testTempPermissionMultipleTabs() {
+  let uri = NetUtil.newURI("https://example.com");
+  let id = "geo";
+
+  let tab1 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, uri.spec);
+  let tab2 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, uri.spec);
+
+  SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, tab2.linkedBrowser);
+
+  Assert.deepEqual(SitePermissions.get(uri, id, tab2.linkedBrowser), {
+    state: SitePermissions.BLOCK,
+    scope: SitePermissions.SCOPE_TEMPORARY,
+  });
+
+  Assert.deepEqual(SitePermissions.get(uri, id, tab1.linkedBrowser), {
+    state: SitePermissions.UNKNOWN,
+    scope: SitePermissions.SCOPE_PERSISTENT,
+  });
+
+  let geoIcon = document.querySelector(".blocked-permission-icon[data-permission-id=geo]");
+
+  Assert.notEqual(geoIcon.boxObject.width, 0, "geo anchor should be visible");
+
+  yield BrowserTestUtils.switchTab(gBrowser, tab1);
+
+  Assert.equal(geoIcon.boxObject.width, 0, "geo anchor should not be visible");
+
+  SitePermissions.remove(uri, id, tab2.linkedBrowser);
+  yield BrowserTestUtils.removeTab(tab1);
+  yield BrowserTestUtils.removeTab(tab2);
+});
+
+// Test that temp blocked permissions requested by subframes (with a different URI) affect the whole page.
+add_task(function* testTempPermissionSubframes() {
+  let uri = NetUtil.newURI("https://example.com");
+  let id = "geo";
+
+  yield BrowserTestUtils.withNewTab(SUBFRAME_PAGE, function*(browser) {
+    let popupshown = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+
+    // Request a permission;
+    yield ContentTask.spawn(browser, uri.host, function(host) {
+      E10SUtils.wrapHandlingUserInput(content, true, function() {
+        let frame = content.document.getElementById("frame");
+        let frameDoc = frame.contentWindow.document;
+
+        // Make sure that the origin of our test page is different.
+        Assert.notEqual(frameDoc.location.host, host);
+
+        frameDoc.getElementById("geo").click();
+      });
+    });
+
+    yield popupshown;
+
+    let popuphidden = BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+
+    let notification = PopupNotifications.panel.firstChild;
+    EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+
+    yield popuphidden;
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_temporary_permissions_navigation.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+
+// Test that temporary permissions are removed on user initiated reload only.
+add_task(function* testTempPermissionOnReload() {
+  let uri = NetUtil.newURI("https://example.com");
+  let id = "geo";
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function*(browser) {
+    let reloadButton = document.getElementById("urlbar-reload-button");
+
+    SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+    let reloaded = BrowserTestUtils.browserLoaded(browser, false, uri.spec);
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    // Reload through the page (should not remove the temp permission).
+    yield ContentTask.spawn(browser, {}, () => content.document.location.reload());
+
+    yield reloaded;
+    yield BrowserTestUtils.waitForCondition(() => {
+      return reloadButton.disabled == false;
+    });
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    // Reload as a user (should remove the temp permission).
+    EventUtils.synthesizeMouseAtCenter(reloadButton, {});
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.UNKNOWN,
+      scope: SitePermissions.SCOPE_PERSISTENT,
+    });
+
+    SitePermissions.remove(uri, id, browser);
+  });
+});
+
+// Test that temporary permissions are persisted through navigation in a tab.
+add_task(function* testTempPermissionOnNavigation() {
+  let uri = NetUtil.newURI("https://example.com/");
+  let id = "geo";
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function*(browser) {
+    SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    let loaded = BrowserTestUtils.browserLoaded(browser, false, "https://example.org/");
+
+    // Navigate to another domain.
+    yield ContentTask.spawn(browser, {}, () => content.document.location = "https://example.org/");
+
+    yield loaded;
+
+    // The temporary permissions for the current URI should be reset.
+    Assert.deepEqual(SitePermissions.get(browser.currentURI, id, browser), {
+      state: SitePermissions.UNKNOWN,
+      scope: SitePermissions.SCOPE_PERSISTENT,
+    });
+
+    loaded = BrowserTestUtils.browserLoaded(browser, false, uri.spec);
+
+    // Navigate to the original domain.
+    yield ContentTask.spawn(browser, {}, () => content.document.location = "https://example.com/");
+
+    yield loaded;
+
+    // The temporary permissions for the original URI should still exist.
+    Assert.deepEqual(SitePermissions.get(browser.currentURI, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    SitePermissions.remove(uri, id, browser);
+  });
+});
+
--- a/browser/base/content/test/general/permissions.html
+++ b/browser/base/content/test/general/permissions.html
@@ -4,10 +4,11 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 <html dir="ltr" xml:lang="en-US" lang="en-US">
   <head>
     <meta charset="utf8">
   </head>
   <body>
 	<!-- This page could eventually request permissions from content
 	     and make sure that chrome responds appropriately -->
+  <button id="geo" onclick="navigator.geolocation.getCurrentPosition(() => {})">Geolocation</button>
   </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/temporary_permissions_subframe.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Temporary Permissions Subframe Test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+  <iframe id="frame" src="https://example.org/browser/browser/base/content/test/general/permissions.html" />
+</body>
+</html>
--- a/browser/base/content/test/webrtc/browser_devices_get_user_media.js
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media.js
@@ -114,16 +114,44 @@ var gTests = [
 
     yield promiseMessage(permissionError, () => {
       activateSecondaryAction(kActionDeny);
     });
 
     yield expectObserverCalled("getUserMedia:response:deny");
     yield expectObserverCalled("recording-window-ended");
     yield checkNotSharing();
+
+    // Verify that we set 'Temporarily blocked' permissions.
+    let browser = gBrowser.selectedBrowser;
+    let blockedPerms = document.getElementById("blocked-permissions-container");
+
+    let {state, scope} = SitePermissions.get(null, "camera", browser);
+    Assert.equal(state, SitePermissions.BLOCK);
+    Assert.equal(scope, SitePermissions.SCOPE_TEMPORARY);
+    ok(blockedPerms.querySelector(".blocked-permission-icon.camera-icon[showing=true]"),
+       "the blocked camera icon is shown");
+
+    ({state, scope} = SitePermissions.get(null, "microphone", browser));
+    Assert.equal(state, SitePermissions.BLOCK);
+    Assert.equal(scope, SitePermissions.SCOPE_TEMPORARY);
+    ok(blockedPerms.querySelector(".blocked-permission-icon.microphone-icon[showing=true]"),
+       "the blocked microphone icon is shown");
+
+    info("requesting devices again to check temporarily blocked permissions");
+    promise = promiseMessage(permissionError);
+    yield promiseRequestDevice(true, true);
+    yield promise;
+    yield expectObserverCalled("getUserMedia:request");
+    yield expectObserverCalled("getUserMedia:response:deny");
+    yield expectObserverCalled("recording-window-ended");
+    yield checkNotSharing();
+
+    SitePermissions.remove(browser.currentURI, "camera", browser);
+    SitePermissions.remove(browser.currentURI, "microphone", browser);
   }
 },
 
 {
   desc: "getUserMedia audio+video: stop sharing",
   run: function* checkStopSharing() {
     let promise = promisePopupNotificationShown("webRTC-shareDevices");
     yield promiseRequestDevice(true, true);
@@ -176,17 +204,17 @@ var gTests = [
 },
 
 {
   desc: "getUserMedia prompt: Always/Never Share",
   run: function* checkRememberCheckbox() {
     let elt = id => document.getElementById(id);
 
     function* checkPerm(aRequestAudio, aRequestVideo,
-                       aExpectedAudioPerm, aExpectedVideoPerm, aNever) {
+                        aExpectedAudioPerm, aExpectedVideoPerm, aNever) {
       let promise = promisePopupNotificationShown("webRTC-shareDevices");
       yield promiseRequestDevice(aRequestAudio, aRequestVideo);
       yield promise;
       yield expectObserverCalled("getUserMedia:request");
 
       is(elt("webRTC-selectMicrophone").hidden, !aRequestAudio,
          "microphone selector expected to be " + (aRequestAudio ? "visible" : "hidden"));
 
@@ -275,16 +303,19 @@ var gTests = [
         yield expectObserverCalled("getUserMedia:request");
 
         // Deny the request to cleanup...
         yield promiseMessage(permissionError, () => {
           activateSecondaryAction(kActionDeny);
         });
         yield expectObserverCalled("getUserMedia:response:deny");
         yield expectObserverCalled("recording-window-ended");
+        let browser = gBrowser.selectedBrowser;
+        SitePermissions.remove(null, "camera", browser);
+        SitePermissions.remove(null, "microphone", browser);
       } else {
         let expectedMessage = aExpectStream ? "ok" : permissionError;
         let promise = promiseMessage(expectedMessage);
         yield promiseRequestDevice(aRequestAudio, aRequestVideo);
         yield promise;
 
         if (expectedMessage == "ok") {
           yield expectObserverCalled("getUserMedia:request");
--- a/browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js
@@ -330,16 +330,18 @@ var gTests = [
 
     yield promiseMessage(permissionError, () => {
       PopupNotifications.panel.firstChild.button.click();
     });
 
     yield expectObserverCalled("getUserMedia:response:deny");
     yield expectObserverCalled("recording-window-ended");
     yield checkNotSharing();
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
   }
 },
 
 
 {
   desc: "getUserMedia screen, user clicks \"Don't Allow\"",
   run: function* checkDontShare() {
     let promise = promisePopupNotificationShown("webRTC-shareDevices");
@@ -350,16 +352,18 @@ var gTests = [
 
     yield promiseMessage(permissionError, () => {
       activateSecondaryAction(kActionDeny);
     });
 
     yield expectObserverCalled("getUserMedia:response:deny");
     yield expectObserverCalled("recording-window-ended");
     yield checkNotSharing();
+    SitePermissions.remove(null, "screen", gBrowser.selectedBrowser);
+    SitePermissions.remove(null, "camera", gBrowser.selectedBrowser);
   }
 },
 
 {
   desc: "getUserMedia audio+video+screen: stop sharing",
   run: function* checkStopSharing() {
     if (AppConstants.platform == "macosx") {
       todo(false, "Bug 1323481 - On Mac on treeherder, but not locally, requesting microphone + screen never makes the permission prompt appear, and so causes the test to timeout");
@@ -494,20 +498,20 @@ var gTests = [
 
     yield closeStream();
   }
 },
 
 {
   desc: "Only persistent block is possible for screen sharing",
   run: function* checkPersistentPermissions() {
-    let Perms = Services.perms;
-    let uri = gBrowser.selectedBrowser.documentURI;
-    let devicePerms = Perms.testExactPermission(uri, "screen");
-    is(devicePerms, Perms.UNKNOWN_ACTION,
+    let browser = gBrowser.selectedBrowser;
+    let uri = browser.documentURI;
+    let devicePerms = SitePermissions.get(uri, "screen", browser);
+    is(devicePerms.state, SitePermissions.UNKNOWN,
        "starting without screen persistent permissions");
 
     let promise = promisePopupNotificationShown("webRTC-shareDevices");
     yield promiseRequestDevice(false, true, null, "screen");
     yield promise;
     yield expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(false, false, true);
     document.getElementById("webRTC-selectWindow-menulist")
@@ -528,27 +532,30 @@ var gTests = [
     // Click "Don't Allow" to save a persistent block permission.
     yield promiseMessage(permissionError, () => {
       activateSecondaryAction(kActionDeny);
     });
     yield expectObserverCalled("getUserMedia:response:deny");
     yield expectObserverCalled("recording-window-ended");
     yield checkNotSharing();
 
-    is(Perms.testExactPermission(uri, "screen"), Perms.DENY_ACTION,
+    let permission = SitePermissions.get(uri, "screen", browser);
+    is(permission.state, SitePermissions.BLOCK,
+       "screen sharing is blocked");
+    is(permission.scope, SitePermissions.SCOPE_PERSISTENT,
        "screen sharing is persistently blocked");
 
     // Request screensharing again, expect an immediate failure.
     promise = promiseMessage(permissionError);
     yield promiseRequestDevice(false, true, null, "screen");
     yield promise;
     yield expectObserverCalled("recording-window-ended");
 
     // Now set the permission to allow and expect a prompt.
-    Perms.add(uri, "screen", Perms.ALLOW_ACTION);
+    SitePermissions.set(uri, "screen", SitePermissions.ALLOW);
 
     // Request devices and expect a prompt despite the saved 'Allow' permission.
     promise = promisePopupNotificationShown("webRTC-shareDevices");
     yield promiseRequestDevice(false, true, null, "screen");
     yield promise;
     yield expectObserverCalled("getUserMedia:request");
 
     // The 'remember' checkbox shouldn't be checked anymore.
@@ -559,17 +566,17 @@ var gTests = [
     ok(!checkbox.checked, "checkbox is not checked");
 
     // Deny the request to cleanup...
     yield promiseMessage(permissionError, () => {
       activateSecondaryAction(kActionDeny);
     });
     yield expectObserverCalled("getUserMedia:response:deny");
     yield expectObserverCalled("recording-window-ended");
-    Perms.remove(uri, "screen");
+    SitePermissions.remove(uri, "screen", browser);
   }
 }
 
 ];
 
 function test() {
   waitForExplicitFinish();
 
--- a/browser/base/content/test/webrtc/head.js
+++ b/browser/base/content/test/webrtc/head.js
@@ -1,9 +1,10 @@
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource:///modules/SitePermissions.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
   "resource://gre/modules/Promise.jsm");
 
 const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
 const CONTENT_SCRIPT_HELPER = getRootDirectory(gTestPath) + "get_user_media_content_script.js";
 
 function waitForCondition(condition, nextTest, errorMsg, retryTimes) {
--- a/browser/locales/en-US/chrome/browser/sitePermissions.properties
+++ b/browser/locales/en-US/chrome/browser/sitePermissions.properties
@@ -1,16 +1,17 @@
 # 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/.
 
 allow = Allow
 allowForSession = Allow for Session
 allowTemporarily = Allow Temporarily
 block = Block
+blockTemporarily = Block Temporarily
 alwaysAsk = Always Ask
 
 permission.cookie.label = Set Cookies
 permission.desktop-notification2.label = Receive Notifications
 permission.image.label = Load Images
 permission.camera.label = Use the Camera
 permission.microphone.label = Use the Microphone
 permission.screen.label = Share the Screen
--- a/browser/modules/PermissionUI.jsm
+++ b/browser/modules/PermissionUI.jsm
@@ -60,16 +60,18 @@ this.EXPORTED_SYMBOLS = [
  * the caller having called into createPermissionPrompt.
  */
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
   "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SitePermissions",
+  "resource:///modules/SitePermissions.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
   return Services.strings
                  .createBundle('chrome://branding/locale/brand.properties');
 });
 
@@ -209,27 +211,20 @@ this.PermissionPromptPrototype = {
    * via a dropdown menu. The first item in this array will be
    * the default selection. Each action is an Object with the
    * following properties:
    *
    *  label (string):
    *    The label that will be displayed for this choice.
    *  accessKey (string):
    *    The access key character that will be used for this choice.
-   *  action (Ci.nsIPermissionManager action, optional)
-   *    The nsIPermissionManager action that will be associated with
-   *    this choice. For example, Ci.nsIPermissionManager.DENY_ACTION.
+   *  action (SitePermissions state)
+   *    The action that will be associated with this choice.
+   *    This should be either SitePermissions.ALLOW or SitePermissions.BLOCK.
    *
-   *    If omitted, the nsIPermissionManager will not be written to
-   *    when this choice is chosen.
-   *  expireType (Ci.nsIPermissionManager expiration policy, optional)
-   *    The nsIPermissionManager expiration policy that will be associated
-   *    with this choice. For example, Ci.nsIPermissionManager.EXPIRE_SESSION.
-   *
-   *    If action is not set, expireType will be ignored.
    *  callback (function, optional)
    *    A callback function that will fire if the user makes this choice, with
    *    a single parameter, state. State is an Object that contains the property
    *    checkboxChecked, which identifies whether the checkbox to remember this
    *    decision was checked.
    */
   get promptActions() {
     return [];
@@ -266,62 +261,68 @@ this.PermissionPromptPrototype = {
     if (!(requestingURI instanceof Ci.nsIStandardURL)) {
       return;
     }
 
     if (this.permissionKey) {
       // If we're reading and setting permissions, then we need
       // to check to see if we already have a permission setting
       // for this particular principal.
-      let result =
-        Services.perms.testExactPermissionFromPrincipal(this.principal,
-                                                        this.permissionKey);
+      let {state} = SitePermissions.get(requestingURI,
+                                        this.permissionKey,
+                                        this.browser);
 
-      if (result == Ci.nsIPermissionManager.DENY_ACTION) {
+      if (state == SitePermissions.BLOCK) {
         this.cancel();
         return;
       }
 
-      if (result == Ci.nsIPermissionManager.ALLOW_ACTION) {
+      if (state == SitePermissions.ALLOW) {
         this.allow();
         return;
       }
     }
 
     // Transform the PermissionPrompt actions into PopupNotification actions.
     let popupNotificationActions = [];
     for (let promptAction of this.promptActions) {
-      // Don't offer action in PB mode if the action remembers permission
-      // for more than a session.
-      if (PrivateBrowsingUtils.isWindowPrivate(chromeWin) &&
-          promptAction.expireType != Ci.nsIPermissionManager.EXPIRE_SESSION &&
-          promptAction.action) {
-        continue;
-      }
-
       let action = {
         label: promptAction.label,
         accessKey: promptAction.accessKey,
         callback: state => {
           if (promptAction.callback) {
             promptAction.callback();
           }
 
           if (this.permissionKey) {
-            // Remember permissions.
-            if (state && state.checkboxChecked && promptAction.action) {
-              Services.perms.addFromPrincipal(this.principal,
-                                              this.permissionKey,
-                                              promptAction.action,
-                                              promptAction.expireType);
+
+            // Permanently store permission.
+            if (state && state.checkboxChecked) {
+              let scope = SitePermissions.SCOPE_PERSISTENT;
+              // Only remember permission for session if in PB mode.
+              if (PrivateBrowsingUtils.isBrowserPrivate(this.browser)) {
+                scope = SitePermissions.SCOPE_SESSION;
+              }
+              SitePermissions.set(this.principal.URI,
+                                  this.permissionKey,
+                                  promptAction.action,
+                                  scope);
+            } else if (promptAction.action == SitePermissions.BLOCK) {
+              // Temporarily store BLOCK permissions only.
+              // SitePermissions does not consider subframes when storing temporary
+              // permissions on a tab, thus storing ALLOW could be exploited.
+              SitePermissions.set(this.principal.URI,
+                                  this.permissionKey,
+                                  promptAction.action,
+                                  SitePermissions.SCOPE_TEMPORARY,
+                                  this.browser);
             }
 
-            // Grant permission if action is null or ALLOW_ACTION.
-            if (!promptAction.action ||
-                promptAction.action == Ci.nsIPermissionManager.ALLOW_ACTION) {
+            // Grant permission if action is ALLOW.
+            if (promptAction.action == SitePermissions.ALLOW) {
               this.allow();
             } else {
               this.cancel();
             }
           }
         },
       };
       if (promptAction.dismiss) {
@@ -474,33 +475,29 @@ GeolocationPermissionPrompt.prototype = 
       Ci.nsISecurityUITelemetry.WARNING_GEOLOCATION_REQUEST_NEVER_SHARE;
 
     let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
 
     return [{
       label: gBrowserBundle.GetStringFromName("geolocation.allowLocation"),
       accessKey:
         gBrowserBundle.GetStringFromName("geolocation.allowLocation.accesskey"),
-      action: null,
-      expireType: null,
+      action: SitePermissions.ALLOW,
       callback(state) {
         if (state && state.checkboxChecked) {
           secHistogram.add(ALWAYS_SHARE);
         } else {
           secHistogram.add(SHARE_LOCATION);
         }
       },
     }, {
       label: gBrowserBundle.GetStringFromName("geolocation.dontAllowLocation"),
       accessKey:
         gBrowserBundle.GetStringFromName("geolocation.dontAllowLocation.accesskey"),
-      action: Ci.nsIPermissionManager.DENY_ACTION,
-      expireType: PrivateBrowsingUtils.isWindowPrivate(this.browser.ownerGlobal) ?
-                  Ci.nsIPermissionManager.EXPIRE_SESSION :
-                  null,
+      action: SitePermissions.BLOCK,
       callback(state) {
         if (state && state.checkboxChecked) {
           secHistogram.add(NEVER_SHARE);
         }
       },
     }];
   },
 
@@ -574,28 +571,22 @@ DesktopNotificationPermissionPrompt.prot
   },
 
   get promptActions() {
     return [
       {
         label: gBrowserBundle.GetStringFromName("webNotifications.allow"),
         accessKey:
           gBrowserBundle.GetStringFromName("webNotifications.allow.accesskey"),
-        action: Ci.nsIPermissionManager.ALLOW_ACTION,
-        expireType: PrivateBrowsingUtils.isBrowserPrivate(this.browser) ?
-                    Ci.nsIPermissionManager.EXPIRE_SESSION :
-                    null,
+        action: SitePermissions.ALLOW,
       },
       {
         label: gBrowserBundle.GetStringFromName("webNotifications.dontAllow"),
         accessKey:
           gBrowserBundle.GetStringFromName("webNotifications.dontAllow.accesskey"),
-        action: Ci.nsIPermissionManager.DENY_ACTION,
-        expireType: PrivateBrowsingUtils.isBrowserPrivate(this.browser) ?
-                    Ci.nsIPermissionManager.EXPIRE_SESSION :
-                    null,
+        action: SitePermissions.BLOCK,
       },
     ];
   },
 };
 
 PermissionUI.DesktopNotificationPermissionPrompt =
   DesktopNotificationPermissionPrompt;
--- a/browser/modules/SitePermissions.jsm
+++ b/browser/modules/SitePermissions.jsm
@@ -1,194 +1,517 @@
 /* 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/. */
 
 this.EXPORTED_SYMBOLS = [ "SitePermissions" ];
 
 Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 var gStringBundle =
   Services.strings.createBundle("chrome://browser/locale/sitePermissions.properties");
 
+/**
+ * A helper module to manage temporarily blocked permissions.
+ *
+ * Permissions are keyed by browser, so methods take a Browser
+ * element to identify the corresponding permission set.
+ *
+ * This uses a WeakMap to key browsers, so that entries are
+ * automatically cleared once the browser stops existing
+ * (once there are no other references to the browser object);
+ */
+const TemporaryBlockedPermissions = {
+  // This is a three level deep map with the following structure:
+  //
+  // Browser => {
+  //   <prePath>: {
+  //     <permissionID>: {Number} <timeStamp>
+  //   }
+  // }
+  //
+  // Only the top level browser elements are stored via WeakMap. The WeakMap
+  // value is an object with URI prePaths as keys. The keys of that object
+  // are ids that identify permissions that were set for the specific URI.
+  // The final value is an object containing the timestamp of when the permission
+  // was set (in order to invalidate after a certain amount of time has passed).
+  _stateByBrowser: new WeakMap(),
+
+  // Private helper method that bundles some shared behavior for
+  // get() and getAll(), e.g. deleting permissions when they have expired.
+  _get(entry, prePath, id, timeStamp) {
+    if (timeStamp == null) {
+      delete entry[prePath][id];
+      return null;
+    }
+    if (timeStamp + SitePermissions.temporaryPermissionExpireTime < Date.now()) {
+      delete entry[prePath][id];
+      return null;
+    }
+    return {id, state: SitePermissions.BLOCK, scope: SitePermissions.SCOPE_TEMPORARY};
+  },
+
+  // Sets a new permission for the specified browser.
+  set(browser, id) {
+    if (!browser) {
+      return;
+    }
+    if (!this._stateByBrowser.has(browser)) {
+      this._stateByBrowser.set(browser, {});
+    }
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (!entry[prePath]) {
+      entry[prePath] = {};
+    }
+    entry[prePath][id] = Date.now();
+  },
+
+  // Removes a permission with the specified id for the specified browser.
+  remove(browser, id) {
+    if (!browser) {
+      return;
+    }
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (entry && entry[prePath]) {
+      delete entry[prePath][id];
+    }
+  },
+
+  // Gets a permission with the specified id for the specified browser.
+  get(browser, id) {
+    if (!browser || !browser.currentURI) {
+      return null;
+    }
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (entry && entry[prePath]) {
+      let permission = entry[prePath][id];
+      return this._get(entry, prePath, id, permission);
+    }
+    return null;
+  },
+
+  // Gets all permissions for the specified browser.
+  // Note that only permissions that apply to the current URI
+  // of the passed browser element will be returned.
+  getAll(browser) {
+    let permissions = [];
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (entry && entry[prePath]) {
+      let timeStamps = entry[prePath];
+      for (let id of Object.keys(timeStamps)) {
+        let permission = this._get(entry, prePath, id, timeStamps[id]);
+        // _get() returns null when the permission has expired.
+        if (permission) {
+          permissions.push(permission);
+        }
+      }
+    }
+    return permissions;
+  },
+
+  // Clears all permissions for the specified browser.
+  // Unlike other methods, this does NOT clear only for
+  // the currentURI but the whole browser state.
+  clear(browser) {
+    this._stateByBrowser.delete(browser);
+  },
+
+  // Copies the temporary permission state of one browser
+  // into a new entry for the other browser.
+  copy(browser, newBrowser) {
+    let entry = this._stateByBrowser.get(browser);
+    if (entry) {
+      this._stateByBrowser.set(newBrowser, entry);
+    }
+  },
+};
+
 this.SitePermissions = {
-
+  // Permission states.
   UNKNOWN: Services.perms.UNKNOWN_ACTION,
   ALLOW: Services.perms.ALLOW_ACTION,
   BLOCK: Services.perms.DENY_ACTION,
-  SESSION: Components.interfaces.nsICookiePermission.ACCESS_SESSION,
+  ALLOW_COOKIES_FOR_SESSION: Components.interfaces.nsICookiePermission.ACCESS_SESSION,
 
-  /* Returns all custom permissions for a given URI, the return
-   * type is a list of objects with the keys:
-   * - id: the permissionId of the permission
-   * - state: a constant representing the current permission state
-   *   (e.g. SitePermissions.ALLOW)
+  // Permission scopes.
+  SCOPE_REQUEST: "{SitePermissions.SCOPE_REQUEST}",
+  SCOPE_TEMPORARY: "{SitePermissions.SCOPE_TEMPORARY}",
+  SCOPE_SESSION: "{SitePermissions.SCOPE_SESSION}",
+  SCOPE_PERSISTENT: "{SitePermissions.SCOPE_PERSISTENT}",
+
+  /**
+   * Gets all custom permissions for a given URI.
+   * Install addon permission is excluded, check bug 1303108.
    *
-   * To receive a more detailed, albeit less performant listing see
-   * SitePermissions.getPermissionDetailsByURI().
-   *
-   * install addon permission is excluded, check bug 1303108
+   * @return {Array} a list of objects with the keys:
+   *          - id: the permissionId of the permission
+   *          - scope: the scope of the permission (e.g. SitePermissions.SCOPE_TEMPORARY)
+   *          - state: a constant representing the current permission state
+   *            (e.g. SitePermissions.ALLOW)
    */
-  getAllByURI(aURI) {
+  getAllByURI(uri) {
     let result = [];
-    if (!this.isSupportedURI(aURI)) {
+    if (!this.isSupportedURI(uri)) {
       return result;
     }
 
-    let permissions = Services.perms.getAllForURI(aURI);
+    let permissions = Services.perms.getAllForURI(uri);
     while (permissions.hasMoreElements()) {
       let permission = permissions.getNext();
 
       // filter out unknown permissions
       if (gPermissionObject[permission.type]) {
         // XXX Bug 1303108 - Control Center should only show non-default permissions
         if (permission.type == "install") {
           continue;
         }
+        let scope = this.SCOPE_PERSISTENT;
+        if (permission.expireType == Services.perms.EXPIRE_SESSION) {
+          scope = this.SCOPE_SESSION;
+        }
         result.push({
           id: permission.type,
+          scope,
           state: permission.capability,
         });
       }
     }
 
     return result;
   },
 
-  /* Returns an object representing the aId permission. It contains the
-   * following keys:
-   * - id: the permissionID of the permission
-   * - label: the localized label
-   * - state: a constant representing the aState permission state
-   *   (e.g. SitePermissions.ALLOW), or the default if aState is omitted
-   * - availableStates: an array of all available states for that permission,
-   *   represented as objects with the keys:
-   *   - id: the state constant
-   *   - label: the translated label of that state
+  /**
+   * Returns detailed information on the specified permission.
+   *
+   * @param {String} id
+   *        The permissionID of the permission.
+   * @param {SitePermissions scope} scope
+   *        The current scope of the permission.
+   * @param {SitePermissions state} state (optional)
+   *        The current state of the permission.
+   *        Will default to the default state if omitted.
+   *
+   * @return {Object} an object with the keys:
+   *           - id: the permissionID of the permission
+   *           - label: the localized label
+   *           - state: the passed in state argument
+   *           - scope: the passed in scope argument
+   *           - availableStates: an array of all available states for that permission,
+   *             represented as objects with the keys:
+   *             - id: the state constant
+   *             - label: the translated label of that state
    */
-  getPermissionItem(aId, aState) {
-    let availableStates = this.getAvailableStates(aId).map(state => {
-      return { id: state, label: this.getStateLabel(aId, state) };
+  getPermissionDetails(id, scope, state = this.getDefault(id)) {
+    let availableStates = this.getAvailableStates(id).map(val => {
+      return { id: val, label: this.getStateLabel(val) };
     });
-    if (aState == undefined)
-      aState = this.getDefault(aId);
-    return {id: aId, label: this.getPermissionLabel(aId),
-            state: aState, availableStates};
+    return {id, label: this.getPermissionLabel(id), state, scope, availableStates};
   },
 
-  /* Returns a list of objects representing all permissions that are currently
-   * set for the given URI. See getPermissionItem for the content of each object.
+  /**
+   * Returns all custom permissions for a given browser.
+   *
+   * To receive a more detailed, albeit less performant listing see
+   * SitePermissions.getAllPermissionDetailsForBrowser().
+   *
+   * @param {Browser} browser
+   *        The browser to fetch permission for.
+   *
+   * @return {Array} a list of objects with the keys:
+   *         - id: the permissionId of the permission
+   *         - state: a constant representing the current permission state
+   *           (e.g. SitePermissions.ALLOW)
+   *         - scope: a constant representing how long the permission will
+   *           be kept.
    */
-  getPermissionDetailsByURI(aURI) {
-    let permissions = [];
-    for (let {state, id} of this.getAllByURI(aURI)) {
-      permissions.push(this.getPermissionItem(id, state));
+  getAllForBrowser(browser) {
+    let permissions = {};
+
+    for (let permission of TemporaryBlockedPermissions.getAll(browser)) {
+      permission.scope = this.SCOPE_TEMPORARY;
+      permissions[permission.id] = permission;
+    }
+
+    for (let permission of this.getAllByURI(browser.currentURI)) {
+      permissions[permission.id] = permission;
     }
 
-    return permissions;
+    return Object.values(permissions);
   },
 
-  /* Checks whether a UI for managing permissions should be exposed for a given
+  /**
+   * Returns a list of objects with detailed information on all permissions
+   * that are currently set for the given browser.
+   *
+   * @param {Browser} browser
+   *        The browser to fetch permission for.
+   *
+   * @return {Array} a list of objects. See getPermissionDetails for the content of each object.
+   */
+  getAllPermissionDetailsForBrowser(browser) {
+    return this.getAllForBrowser(browser).map(({id, scope, state}) =>
+      this.getPermissionDetails(id, scope, state));
+  },
+
+  /**
+   * Checks whether a UI for managing permissions should be exposed for a given
    * URI. This excludes file URIs, for instance, as they don't have a host,
    * even though nsIPermissionManager can still handle them.
+   *
+   * @param {nsIURI} uri
+   *        The URI to check.
+   *
+   * @return {boolean} if the URI is supported.
    */
-  isSupportedURI(aURI) {
-    return aURI.schemeIs("http") || aURI.schemeIs("https");
+  isSupportedURI(uri) {
+    return uri && (uri.schemeIs("http") || uri.schemeIs("https"));
   },
 
-  /* Returns an array of all permission IDs.
+  /**
+   * Gets an array of all permission IDs.
+   *
+   * @return {Array<String>} an array of all permission IDs.
    */
   listPermissions() {
     return Object.keys(gPermissionObject);
   },
 
-  /* Returns an array of permission states to be exposed to the user for a
+  /**
+   * Returns an array of permission states to be exposed to the user for a
    * permission with the given ID.
+   *
+   * @param {string} permissionID
+   *        The ID to get permission states for.
+   *
+   * @return {Array<SitePermissions state>} an array of all permission states.
    */
-  getAvailableStates(aPermissionID) {
-    if (aPermissionID in gPermissionObject &&
-        gPermissionObject[aPermissionID].states)
-      return gPermissionObject[aPermissionID].states;
+  getAvailableStates(permissionID) {
+    if (permissionID in gPermissionObject &&
+        gPermissionObject[permissionID].states)
+      return gPermissionObject[permissionID].states;
 
-    if (this.getDefault(aPermissionID) == this.UNKNOWN)
+    if (this.getDefault(permissionID) == this.UNKNOWN)
       return [ SitePermissions.UNKNOWN, SitePermissions.ALLOW, SitePermissions.BLOCK ];
 
     return [ SitePermissions.ALLOW, SitePermissions.BLOCK ];
   },
 
-  /* Returns the default state of a particular permission.
+  /**
+   * Returns the default state of a particular permission.
+   *
+   * @param {string} permissionID
+   *        The ID to get the default for.
+   *
+   * @return {SitePermissions.state} the default state.
    */
-  getDefault(aPermissionID) {
-    if (aPermissionID in gPermissionObject &&
-        gPermissionObject[aPermissionID].getDefault)
-      return gPermissionObject[aPermissionID].getDefault();
+  getDefault(permissionID) {
+    if (permissionID in gPermissionObject &&
+        gPermissionObject[permissionID].getDefault)
+      return gPermissionObject[permissionID].getDefault();
 
     return this.UNKNOWN;
   },
 
-  /* Returns the state of a particular permission for a given URI.
+  /**
+   * Returns the state and scope of a particular permission for a given URI.
+   *
+   * @param {nsIURI} uri
+   *        The URI to check.
+   * @param {String} permissionID
+   *        The id of the permission.
+   * @param {Browser} browser (optional)
+   *        The browser object to check for temporary permissions.
+   *
+   * @return {Object} an object with the keys:
+   *           - state: The current state of the permission
+   *             (e.g. SitePermissions.ALLOW)
+   *           - scope: The scope of the permission
+   *             (e.g. SitePermissions.SCOPE_PERSISTENT)
    */
-  get(aURI, aPermissionID) {
-    if (!this.isSupportedURI(aURI))
-      return this.UNKNOWN;
+  get(uri, permissionID, browser) {
+    let result = { state: this.UNKNOWN, scope: this.SCOPE_PERSISTENT };
+    if (this.isSupportedURI(uri)) {
+      let permission = null;
+      if (permissionID in gPermissionObject &&
+        gPermissionObject[permissionID].exactHostMatch) {
+        permission = Services.perms.getPermissionObjectForURI(uri, permissionID, true);
+      } else {
+        permission = Services.perms.getPermissionObjectForURI(uri, permissionID, false);
+      }
 
-    let state;
-    if (aPermissionID in gPermissionObject &&
-        gPermissionObject[aPermissionID].exactHostMatch)
-      state = Services.perms.testExactPermission(aURI, aPermissionID);
-    else
-      state = Services.perms.testPermission(aURI, aPermissionID);
-    return state;
+      if (permission) {
+        result.state = permission.capability;
+        if (permission.expireType == Services.perms.EXPIRE_SESSION) {
+          result.scope = this.SCOPE_SESSION;
+        }
+      }
+    }
+
+    if (!result.state) {
+      // If there's no persistent permission saved, check if we have something
+      // set temporarily.
+      let value = TemporaryBlockedPermissions.get(browser, permissionID);
+
+      if (value) {
+        result.state = value.state;
+        result.scope = this.SCOPE_TEMPORARY;
+      }
+    }
+
+    return result;
   },
 
-  /* Sets the state of a particular permission for a given URI.
+  /**
+   * Sets the state of a particular permission for a given URI or browser.
+   *
+   * @param {nsIURI} uri
+   *        The URI to set the permission for.
+   *        Note that this will be ignored if the scope is set to SCOPE_TEMPORARY
+   * @param {String} permissionID
+   *        The id of the permission.
+   * @param {SitePermissions state} state
+   *        The state of the permission.
+   * @param {SitePermissions scope} scope (optional)
+   *        The scope of the permission. Defaults to SCOPE_PERSISTENT.
+   * @param {Browser} browser (optional)
+   *        The browser object to set temporary permissions on.
+   *        This needs to be provided if the scope is SCOPE_TEMPORARY!
    */
-  set(aURI, aPermissionID, aState) {
-    if (!this.isSupportedURI(aURI))
-      return;
-
-    if (aState == this.UNKNOWN) {
-      this.remove(aURI, aPermissionID);
+  set(uri, permissionID, state, scope = this.SCOPE_PERSISTENT, browser = null) {
+    if (state == this.UNKNOWN) {
+      this.remove(uri, permissionID, browser);
       return;
     }
 
-    Services.perms.add(aURI, aPermissionID, aState);
+    if (state == this.ALLOW_COOKIES_FOR_SESSION && permissionID != "cookie") {
+      throw "ALLOW_COOKIES_FOR_SESSION can only be set on the cookie permission";
+    }
+
+    // Save temporary permissions.
+    if (scope == this.SCOPE_TEMPORARY) {
+      // We do not support setting temp ALLOW for security reasons.
+      // In its current state, this permission could be exploited by subframes
+      // on the same page. This is because for BLOCK we ignore the request
+      // URI and only consider the current browser URI, to avoid notification spamming.
+      //
+      // If you ever consider removing this line, you likely want to implement
+      // a more fine-grained TemporaryBlockedPermissions that temporarily blocks for the
+      // entire browser, but temporarily allows only for specific frames.
+      if (state != this.BLOCK) {
+        throw "'Block' is the only permission we can save temporarily on a browser";
+      }
+
+      if (!browser) {
+        throw "TEMPORARY scoped permissions require a browser object";
+      }
+
+      TemporaryBlockedPermissions.set(browser, permissionID);
+
+      browser.dispatchEvent(new browser.ownerGlobal
+                                       .CustomEvent("PermissionStateChange"));
+    } else if (this.isSupportedURI(uri)) {
+      let perms_scope = Services.perms.EXPIRE_NEVER;
+      if (scope == this.SCOPE_SESSION) {
+        perms_scope = Services.perms.EXPIRE_SESSION;
+      }
+
+      Services.perms.add(uri, permissionID, state, perms_scope);
+    }
   },
 
-  /* Removes the saved state of a particular permission for a given URI.
+  /**
+   * Removes the saved state of a particular permission for a given URI and/or browser.
+   *
+   * @param {nsIURI} uri
+   *        The URI to remove the permission for.
+   * @param {String} permissionID
+   *        The id of the permission.
+   * @param {Browser} browser (optional)
+   *        The browser object to remove temporary permissions on.
    */
-  remove(aURI, aPermissionID) {
-    if (!this.isSupportedURI(aURI))
-      return;
+  remove(uri, permissionID, browser) {
+    if (this.isSupportedURI(uri))
+      Services.perms.remove(uri, permissionID);
 
-    Services.perms.remove(aURI, aPermissionID);
+    // TemporaryBlockedPermissions.get() deletes expired permissions automatically,
+    if (TemporaryBlockedPermissions.get(browser, permissionID)) {
+      // If it exists but has not expired, remove it explicitly.
+      TemporaryBlockedPermissions.remove(browser, permissionID);
+      // Send a PermissionStateChange event only if the permission hasn't expired.
+      browser.dispatchEvent(new browser.ownerGlobal
+                                       .CustomEvent("PermissionStateChange"));
+    }
   },
 
-  /* Returns the localized label for the permission with the given ID, to be
-   * used in a UI for managing permissions.
+  /**
+   * Clears all permissions that were temporarily saved.
+   *
+   * @param {Browser} browser
+   *        The browser object to clear.
    */
-  getPermissionLabel(aPermissionID) {
-    let labelID = gPermissionObject[aPermissionID].labelID || aPermissionID;
+  clearTemporaryPermissions(browser) {
+    TemporaryBlockedPermissions.clear(browser);
+  },
+
+  /**
+   * Copy all permissions that were temporarily saved on one
+   * browser object to a new browser.
+   *
+   * @param {Browser} browser
+   *        The browser object to copy from.
+   * @param {Browser} newBrowser
+   *        The browser object to copy to.
+   */
+  copyTemporaryPermissions(browser, newBrowser) {
+    TemporaryBlockedPermissions.copy(browser, newBrowser);
+  },
+
+  /**
+   * Returns the localized label for the permission with the given ID, to be
+   * used in a UI for managing permissions.
+   *
+   * @param {string} permissionID
+   *        The permission to get the label for.
+   *
+   * @return {String} the localized label.
+   */
+  getPermissionLabel(permissionID) {
+    let labelID = gPermissionObject[permissionID].labelID || permissionID;
     return gStringBundle.GetStringFromName("permission." + labelID + ".label");
   },
 
-  /* Returns the localized label for the given permission state, to be used in
+  /**
+   * Returns the localized label for the given permission state, to be used in
    * a UI for managing permissions.
+   *
+   * @param {SitePermissions state} state
+   *        The state to get the label for.
+   * @param {SitePermissions scope} scope (optional)
+   *        The scope to get the label for.
+   *
+   * @return {String} the localized label.
    */
-  getStateLabel(aPermissionID, aState, aInUse = false) {
-    switch (aState) {
+  getStateLabel(state, scope = null) {
+    switch (state) {
       case this.UNKNOWN:
-        if (aInUse)
-          return gStringBundle.GetStringFromName("allowTemporarily");
         return gStringBundle.GetStringFromName("alwaysAsk");
       case this.ALLOW:
+        if (scope && scope != this.SCOPE_PERSISTENT)
+          return gStringBundle.GetStringFromName("allowTemporarily");
         return gStringBundle.GetStringFromName("allow");
-      case this.SESSION:
+      case this.ALLOW_COOKIES_FOR_SESSION:
         return gStringBundle.GetStringFromName("allowForSession");
       case this.BLOCK:
+        if (scope && scope != this.SCOPE_PERSISTENT)
+          return gStringBundle.GetStringFromName("blockTemporarily");
         return gStringBundle.GetStringFromName("block");
       default:
         return null;
     }
   }
 };
 
 var gPermissionObject = {
@@ -217,23 +540,23 @@ var gPermissionObject = {
   "image": {
     getDefault() {
       return Services.prefs.getIntPref("permissions.default.image") == 2 ?
                SitePermissions.BLOCK : SitePermissions.ALLOW;
     }
   },
 
   "cookie": {
-    states: [ SitePermissions.ALLOW, SitePermissions.SESSION, SitePermissions.BLOCK ],
+    states: [ SitePermissions.ALLOW, SitePermissions.ALLOW_COOKIES_FOR_SESSION, SitePermissions.BLOCK ],
     getDefault() {
       if (Services.prefs.getIntPref("network.cookie.cookieBehavior") == 2)
         return SitePermissions.BLOCK;
 
       if (Services.prefs.getIntPref("network.cookie.lifetimePolicy") == 2)
-        return SitePermissions.SESSION;
+        return SitePermissions.ALLOW_COOKIES_FOR_SESSION;
 
       return SitePermissions.ALLOW;
     }
   },
 
   "desktop-notification": {
     exactHostMatch: true,
     labelID: "desktop-notification2",
@@ -261,9 +584,11 @@ var gPermissionObject = {
 
   "geo": {
     exactHostMatch: true
   },
 
   "indexedDB": {}
 };
 
-const kPermissionIDs = Object.keys(gPermissionObject);
+XPCOMUtils.defineLazyPreferenceGetter(SitePermissions, "temporaryPermissionExpireTime",
+                                      "privacy.temporary_permission_expire_time_ms", 3600 * 1000);
+
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -19,16 +19,20 @@ support-files =
 [browser_NetworkPrioritizer.js]
 [browser_PermissionUI.js]
 [browser_ProcessHangNotifications.js]
 skip-if = !e10s
 [browser_SelfSupportBackend.js]
 support-files =
   ../../components/uitour/test/uitour.html
   ../../components/uitour/UITour-lib.js
+[browser_SitePermissions.js]
+[browser_SitePermissions_combinations.js]
+[browser_SitePermissions_expiry.js]
+[browser_SitePermissions_tab_urls.js]
 [browser_taskbar_preview.js]
 skip-if = os != "win"
 [browser_UnsubmittedCrashHandler.js]
 run-if = crashreporter
 [browser_UsageTelemetry.js]
 [browser_UsageTelemetry_private_and_restore.js]
 [browser_UsageTelemetry_urlbar.js]
 support-files =
--- a/browser/modules/test/browser_PermissionUI.js
+++ b/browser/modules/test/browser_PermissionUI.js
@@ -3,16 +3,17 @@
  * permission prompts to the user. It also tests to ensure that
  * add-ons can introduce their own permission prompts.
  */
 
 "use strict";
 
 Cu.import("resource://gre/modules/Integration.jsm", this);
 Cu.import("resource:///modules/PermissionUI.jsm", this);
+Cu.import("resource:///modules/SitePermissions.jsm", this);
 
 /**
  * Given a <xul:browser> at some non-internal web page,
  * return something that resembles an nsIContentPermissionRequest,
  * using the browsers currently loaded document to get a principal.
  *
  * @param browser (<xul:browser>)
  *        The browser that we'll create a nsIContentPermissionRequest
@@ -216,38 +217,36 @@ add_task(function* test_with_permission_
     const kTestNotificationID = "test-notification";
     const kTestMessage = "Test message";
     const kTestPermissionKey = "test-permission-key";
 
     let allowed = false;
     let mainAction = {
       label: "Allow",
       accessKey: "M",
-      action: Ci.nsIPermissionManager.ALLOW_ACTION,
-      expiryType: Ci.nsIPermissionManager.EXPIRE_SESSION,
+      action: SitePermissions.ALLOW,
       callback() {
         allowed = true;
       }
     };
 
     let denied = false;
     let secondaryAction = {
       label: "Deny",
       accessKey: "D",
-      action: Ci.nsIPermissionManager.DENY_ACTION,
-      expiryType: Ci.nsIPermissionManager.EXPIRE_SESSION,
+      action: SitePermissions.BLOCK,
       callback() {
         denied = true;
       }
     };
 
     let mockRequest = makeMockPermissionRequest(browser);
     let principal = mockRequest.principal;
     registerCleanupFunction(function() {
-      Services.perms.removeFromPrincipal(principal, kTestPermissionKey);
+      SitePermissions.remove(principal.URI, kTestPermissionKey);
     });
 
     let TestPrompt = {
       __proto__: PermissionUI.PermissionPromptForRequestPrototype,
       request: mockRequest,
       notificationID: kTestNotificationID,
       permissionKey: kTestPermissionKey,
       message: kTestMessage,
@@ -264,54 +263,90 @@ add_task(function* test_with_permission_
     let shownPromise =
       BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
     TestPrompt.prompt();
     yield shownPromise;
     let notification =
       PopupNotifications.getNotification(kTestNotificationID, browser);
     Assert.ok(notification, "Should have gotten the notification");
 
-    let curPerm =
-      Services.perms.testPermissionFromPrincipal(principal,
-                                                 kTestPermissionKey);
-    Assert.equal(curPerm, Ci.nsIPermissionManager.UNKNOWN_ACTION,
+    let curPerm = SitePermissions.get(principal.URI, kTestPermissionKey, browser);
+    Assert.equal(curPerm.state, SitePermissions.UNKNOWN,
                  "Should be no permission set to begin with.");
 
-    // First test denying the permission request.
+    // First test denying the permission request without the checkbox checked.
+    let popupNotification = getPopupNotificationNode();
+    popupNotification.checkbox.checked = false;
+
     Assert.equal(notification.secondaryActions.length, 1,
                  "There should only be 1 secondary action");
     yield clickSecondaryAction();
-    curPerm = Services.perms.testPermissionFromPrincipal(principal,
-                                                         kTestPermissionKey);
-    Assert.equal(curPerm, Ci.nsIPermissionManager.DENY_ACTION,
-                 "Should have denied the action");
+    curPerm = SitePermissions.get(principal.URI, kTestPermissionKey, browser);
+    Assert.deepEqual(curPerm, {
+                       state: SitePermissions.BLOCK,
+                       scope: SitePermissions.SCOPE_TEMPORARY,
+                     }, "Should have denied the action temporarily");
+    // Try getting the permission without passing the browser object (should fail).
+    curPerm = SitePermissions.get(principal.URI, kTestPermissionKey);
+    Assert.deepEqual(curPerm, {
+                       state: SitePermissions.UNKNOWN,
+                       scope: SitePermissions.SCOPE_PERSISTENT,
+                     }, "Should have made no permanent permission entry");
     Assert.ok(denied, "The secondaryAction callback should have fired");
     Assert.ok(!allowed, "The mainAction callback should not have fired");
     Assert.ok(mockRequest._cancelled,
               "The request should have been cancelled");
     Assert.ok(!mockRequest._allowed,
               "The request should not have been allowed");
 
     // Clear the permission and pretend we never denied
-    Services.perms.removeFromPrincipal(principal, kTestPermissionKey);
+    SitePermissions.remove(principal.URI, kTestPermissionKey, browser);
     denied = false;
     mockRequest._cancelled = false;
 
     // Bring the PopupNotification back up now...
     shownPromise =
       BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
     TestPrompt.prompt();
     yield shownPromise;
 
-    // Next test allowing the permission request.
+    // Test denying the permission request.
+    Assert.equal(notification.secondaryActions.length, 1,
+                 "There should only be 1 secondary action");
+    yield clickSecondaryAction();
+    curPerm = SitePermissions.get(principal.URI, kTestPermissionKey);
+    Assert.deepEqual(curPerm, {
+                       state: SitePermissions.BLOCK,
+                       scope: SitePermissions.SCOPE_PERSISTENT
+                     }, "Should have denied the action");
+    Assert.ok(denied, "The secondaryAction callback should have fired");
+    Assert.ok(!allowed, "The mainAction callback should not have fired");
+    Assert.ok(mockRequest._cancelled,
+              "The request should have been cancelled");
+    Assert.ok(!mockRequest._allowed,
+              "The request should not have been allowed");
+
+    // Clear the permission and pretend we never denied
+    SitePermissions.remove(principal.URI, kTestPermissionKey);
+    denied = false;
+    mockRequest._cancelled = false;
+
+    // Bring the PopupNotification back up now...
+    shownPromise =
+      BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+    TestPrompt.prompt();
+    yield shownPromise;
+
+    // Test allowing the permission request.
     yield clickMainAction();
-    curPerm = Services.perms.testPermissionFromPrincipal(principal,
-                                                         kTestPermissionKey);
-    Assert.equal(curPerm, Ci.nsIPermissionManager.ALLOW_ACTION,
-                 "Should have allowed the action");
+    curPerm = SitePermissions.get(principal.URI, kTestPermissionKey);
+    Assert.deepEqual(curPerm, {
+                       state: SitePermissions.ALLOW,
+                       scope: SitePermissions.SCOPE_PERSISTENT
+                     }, "Should have allowed the action");
     Assert.ok(!denied, "The secondaryAction callback should not have fired");
     Assert.ok(allowed, "The mainAction callback should have fired");
     Assert.ok(!mockRequest._cancelled,
               "The request should not have been cancelled");
     Assert.ok(mockRequest._allowed,
               "The request should have been allowed");
   });
 });
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_SitePermissions.js
@@ -0,0 +1,101 @@
+/* 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";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+
+// This asserts that SitePermissions.set can not save ALLOW permissions
+// temporarily on a tab.
+add_task(function* testTempAllowThrows() {
+  let uri = Services.io.newURI("https://example.com");
+  let id = "notifications";
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function(browser) {
+    Assert.throws(function() {
+      SitePermissions.set(uri, id, SitePermissions.ALLOW, SitePermissions.SCOPE_TEMPORARY, browser);
+    }, "'Block' is the only permission we can save temporarily on a tab");
+  });
+});
+
+// This tests the SitePermissions.getAllPermissionDetailsForBrowser function.
+add_task(function* testGetAllPermissionDetailsForBrowser() {
+  let uri = Services.io.newURI("https://example.com");
+
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, uri.spec);
+
+  SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
+  SitePermissions.set(uri, "cookie", SitePermissions.ALLOW_COOKIES_FOR_SESSION);
+  SitePermissions.set(uri, "popup", SitePermissions.BLOCK);
+  SitePermissions.set(uri, "geo", SitePermissions.ALLOW, SitePermissions.SCOPE_SESSION);
+
+  let permissions = SitePermissions.getAllPermissionDetailsForBrowser(tab.linkedBrowser);
+
+  let camera = permissions.find(({id}) => id === "camera");
+  Assert.deepEqual(camera, {
+    id: "camera",
+    label: "Use the Camera",
+    state: SitePermissions.ALLOW,
+    scope: SitePermissions.SCOPE_PERSISTENT,
+    availableStates: [
+      { id: SitePermissions.UNKNOWN, label: "Always Ask" },
+      { id: SitePermissions.ALLOW, label: "Allow" },
+      { id: SitePermissions.BLOCK, label: "Block" },
+    ]
+  });
+
+  // check that removed permissions (State.UNKNOWN) are skipped
+  SitePermissions.remove(uri, "camera");
+  permissions = SitePermissions.getAllPermissionDetailsForBrowser(tab.linkedBrowser);
+
+  camera = permissions.find(({id}) => id === "camera");
+  Assert.equal(camera, undefined);
+
+  // check that different available state values are represented
+
+  let cookie = permissions.find(({id}) => id === "cookie");
+  Assert.deepEqual(cookie, {
+    id: "cookie",
+    label: "Set Cookies",
+    state: SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+    scope: SitePermissions.SCOPE_PERSISTENT,
+    availableStates: [
+      { id: SitePermissions.ALLOW, label: "Allow" },
+      { id: SitePermissions.ALLOW_COOKIES_FOR_SESSION, label: "Allow for Session" },
+      { id: SitePermissions.BLOCK, label: "Block" },
+    ]
+  });
+
+  let popup = permissions.find(({id}) => id === "popup");
+  Assert.deepEqual(popup, {
+    id: "popup",
+    label: "Open Pop-up Windows",
+    state: SitePermissions.BLOCK,
+    scope: SitePermissions.SCOPE_PERSISTENT,
+    availableStates: [
+      { id: SitePermissions.ALLOW, label: "Allow" },
+      { id: SitePermissions.BLOCK, label: "Block" },
+    ]
+  });
+
+  let geo = permissions.find(({id}) => id === "geo");
+  Assert.deepEqual(geo, {
+    id: "geo",
+    label: "Access Your Location",
+    state: SitePermissions.ALLOW,
+    scope: SitePermissions.SCOPE_SESSION,
+    availableStates: [
+      { id: SitePermissions.UNKNOWN, label: "Always Ask" },
+      { id: SitePermissions.ALLOW, label: "Allow" },
+      { id: SitePermissions.BLOCK, label: "Block" },
+    ]
+  });
+
+  SitePermissions.remove(uri, "cookie");
+  SitePermissions.remove(uri, "popup");
+  SitePermissions.remove(uri, "geo");
+
+  yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_SitePermissions_combinations.js
@@ -0,0 +1,136 @@
+/* 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";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+
+// This function applies combinations of different permissions and
+// checks how they override each other.
+function* checkPermissionCombinations(combinations) {
+  let uri = Services.io.newURI("https://example.com");
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function(browser) {
+    let id = "geo";
+    for (let {reverse, states, result} of combinations) {
+      let loop = () => {
+        for (let [state, scope] of states) {
+          SitePermissions.set(uri, id, state, scope, browser);
+        }
+        Assert.deepEqual(SitePermissions.get(uri, id, browser), result);
+        SitePermissions.remove(uri, id, browser);
+      };
+
+      loop();
+
+      if (reverse) {
+        states.reverse();
+        loop();
+      }
+    }
+  });
+}
+
+// Test that passing null as scope becomes SCOPE_PERSISTENT.
+add_task(function* testDefaultScope() {
+  yield checkPermissionCombinations([{
+    states: [
+      [SitePermissions.ALLOW, null],
+    ],
+    result: {
+      state: SitePermissions.ALLOW,
+      scope: SitePermissions.SCOPE_PERSISTENT,
+    },
+  }]);
+});
+
+// Test that "wide" scopes like PERSISTENT always override "narrower" ones like TAB.
+add_task(function* testScopeOverrides() {
+  yield checkPermissionCombinations([
+    {
+      // The behavior of SCOPE_SESSION is not in line with the general behavior
+      // because of the legacy nsIPermissionManager implementation.
+      states: [
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+      ],
+      result: {
+        state: SitePermissions.BLOCK,
+        scope: SitePermissions.SCOPE_SESSION,
+      },
+    }, {
+      states: [
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+      ],
+      result: {
+        state: SitePermissions.ALLOW,
+        scope: SitePermissions.SCOPE_PERSISTENT,
+      },
+
+    }, {
+      reverse: true,
+      states: [
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_SESSION],
+      ],
+      result: {
+        state: SitePermissions.ALLOW,
+        scope: SitePermissions.SCOPE_SESSION,
+      },
+    }, {
+      reverse: true,
+      states: [
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+      ],
+      result: {
+        state: SitePermissions.ALLOW,
+        scope: SitePermissions.SCOPE_PERSISTENT,
+      },
+    }
+  ]);
+});
+
+// Test that clearing a temporary permission also removes a
+// persistent permission that was set for the same URL.
+add_task(function* testClearTempPermission() {
+  yield checkPermissionCombinations([{
+    states: [
+      [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+      [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+      [SitePermissions.UNKNOWN, SitePermissions.SCOPE_TEMPORARY],
+    ],
+    result: {
+      state: SitePermissions.UNKNOWN,
+      scope: SitePermissions.SCOPE_PERSISTENT,
+    },
+  }]);
+});
+
+// Test that states override each other when applied with the same scope.
+add_task(function* testStateOverride() {
+  yield checkPermissionCombinations([
+    {
+      states: [
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+      ],
+      result: {
+        state: SitePermissions.BLOCK,
+        scope: SitePermissions.SCOPE_PERSISTENT,
+      },
+    }, {
+      states: [
+        [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+        [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+      ],
+      result: {
+        state: SitePermissions.ALLOW,
+        scope: SitePermissions.SCOPE_PERSISTENT,
+      },
+    }
+  ]);
+});
+
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_SitePermissions_expiry.js
@@ -0,0 +1,33 @@
+/* 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";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+
+// This tests the time delay to expire temporary permission entries.
+add_task(function* testTemporaryPermissionExpiry() {
+  SpecialPowers.pushPrefEnv({set: [
+        ["privacy.temporary_permission_expire_time_ms", 100],
+  ]});
+
+  let uri = Services.io.newURI("https://example.com")
+  let id = "camera";
+
+  yield BrowserTestUtils.withNewTab(uri.spec, function*(browser) {
+    SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.BLOCK,
+      scope: SitePermissions.SCOPE_TEMPORARY,
+    });
+
+    yield new Promise((c) => setTimeout(c, 500));
+
+    Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+      state: SitePermissions.UNKNOWN,
+      scope: SitePermissions.SCOPE_PERSISTENT,
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_SitePermissions_tab_urls.js
@@ -0,0 +1,78 @@
+/* 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";
+
+Cu.import("resource:///modules/SitePermissions.jsm", this);
+
+// This tests the key used to store the URI -> permission map on a tab.
+add_task(function* testTemporaryPermissionTabURLs() {
+
+  // Prevent showing a dialog for https://name:password@example.com
+  SpecialPowers.pushPrefEnv({set: [
+        ["network.http.phishy-userpass-length", 2048],
+  ]});
+
+  // This usually takes about 60 seconds on 32bit Linux debug,
+  // due to the combinatory nature of the test that is hard to fix.
+  requestLongerTimeout(2);
+
+  let same = [ "https://example.com", "https://example.com/sub/path", "https://example.com:443" ].map(Services.io.newURI);
+  let different = [ "https://example.com", "https://name:password@example.com", "https://test1.example.com", "http://example.com", "http://example.org" ].map(Services.io.newURI);
+
+  let id = "microphone";
+
+  yield BrowserTestUtils.withNewTab("about:blank", function*(browser) {
+    for (let uri of same) {
+        let loaded = BrowserTestUtils.browserLoaded(browser, false, uri.spec);
+        browser.loadURI(uri.spec);
+        yield loaded;
+
+        SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+        for (let uri2 of same) {
+          let loaded2 = BrowserTestUtils.browserLoaded(browser, false, uri2.spec);
+          browser.loadURI(uri2.spec);
+          yield loaded2;
+
+          Assert.deepEqual(SitePermissions.get(uri2, id, browser), {
+            state: SitePermissions.BLOCK,
+            scope: SitePermissions.SCOPE_TEMPORARY,
+          }, `${uri.spec} should share tab permissions with ${uri2.spec}`);
+        }
+
+        SitePermissions.clearTemporaryPermissions(browser);
+    }
+
+    for (let uri of different) {
+      let loaded = BrowserTestUtils.browserLoaded(browser, false, uri.spec);
+      browser.loadURI(uri.spec);
+      yield loaded;
+
+      SitePermissions.set(uri, id, SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY, browser);
+
+      Assert.deepEqual(SitePermissions.get(uri, id, browser), {
+        state: SitePermissions.BLOCK,
+        scope: SitePermissions.SCOPE_TEMPORARY,
+      });
+
+      for (let uri2 of different) {
+        loaded = BrowserTestUtils.browserLoaded(browser, false, uri2.spec);
+        browser.loadURI(uri2.spec);
+        yield loaded;
+
+        if (uri2 != uri) {
+          Assert.deepEqual(SitePermissions.get(uri2, id, browser), {
+            state: SitePermissions.UNKNOWN,
+            scope: SitePermissions.SCOPE_PERSISTENT,
+          }, `${uri.spec} should not share tab permissions with ${uri2.spec}`);
+        }
+      }
+
+      SitePermissions.clearTemporaryPermissions(browser);
+    }
+  });
+
+});
+
--- a/browser/modules/test/browser_UsageTelemetry.js
+++ b/browser/modules/test/browser_UsageTelemetry.js
@@ -5,16 +5,19 @@ const TAB_EVENT_COUNT = "browser.engagem
 const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
 const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
 const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
 const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
 const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
 
 const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
 
+// Reset internal URI counter in case URIs were opened by other tests.
+Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC, "");
+
 /**
  * Waits for the web progress listener associated with this tab to fire an
  * onLocationChange for a non-error page.
  *
  * @param {xul:browser} browser
  *        A xul:browser.
  *
  * @return {Promise}
--- a/browser/modules/test/xpcshell/test_SitePermissions.js
+++ b/browser/modules/test/xpcshell/test_SitePermissions.js
@@ -19,97 +19,35 @@ add_task(function* testGetAllByURI() {
   let wrongURI = Services.io.newURI("file:///example.js")
   Assert.deepEqual(SitePermissions.getAllByURI(wrongURI), []);
 
   let uri = Services.io.newURI("https://example.com")
   Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
 
   SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
   Assert.deepEqual(SitePermissions.getAllByURI(uri), [
-      { id: "camera", state: SitePermissions.ALLOW }
+      { id: "camera", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_PERSISTENT }
   ]);
 
-  SitePermissions.set(uri, "microphone", SitePermissions.SESSION);
+  SitePermissions.set(uri, "microphone", SitePermissions.ALLOW, SitePermissions.SCOPE_SESSION);
   SitePermissions.set(uri, "desktop-notification", SitePermissions.BLOCK);
 
   Assert.deepEqual(SitePermissions.getAllByURI(uri), [
-      { id: "camera", state: SitePermissions.ALLOW },
-      { id: "microphone", state: SitePermissions.SESSION },
-      { id: "desktop-notification", state: SitePermissions.BLOCK }
+      { id: "camera", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_PERSISTENT },
+      { id: "microphone", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_SESSION },
+      { id: "desktop-notification", state: SitePermissions.BLOCK, scope: SitePermissions.SCOPE_PERSISTENT }
   ]);
 
   SitePermissions.remove(uri, "microphone");
   Assert.deepEqual(SitePermissions.getAllByURI(uri), [
-      { id: "camera", state: SitePermissions.ALLOW },
-      { id: "desktop-notification", state: SitePermissions.BLOCK }
+      { id: "camera", state: SitePermissions.ALLOW, scope: SitePermissions.SCOPE_PERSISTENT },
+      { id: "desktop-notification", state: SitePermissions.BLOCK, scope: SitePermissions.SCOPE_PERSISTENT }
   ]);
 
   SitePermissions.remove(uri, "camera");
   SitePermissions.remove(uri, "desktop-notification");
   Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
 
   // XXX Bug 1303108 - Control Center should only show non-default permissions
   SitePermissions.set(uri, "addon", SitePermissions.BLOCK);
   Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
   SitePermissions.remove(uri, "addon");
 });
-
-add_task(function* testGetPermissionDetailsByURI() {
-  // check that it returns an empty array on an invalid URI
-  // like a file URI, which doesn't support site permissions
-  let wrongURI = Services.io.newURI("file:///example.js")
-  Assert.deepEqual(SitePermissions.getPermissionDetailsByURI(wrongURI), []);
-
-  let uri = Services.io.newURI("https://example.com")
-
-  SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
-  SitePermissions.set(uri, "cookie", SitePermissions.SESSION);
-  SitePermissions.set(uri, "popup", SitePermissions.BLOCK);
-
-  let permissions = SitePermissions.getPermissionDetailsByURI(uri);
-
-  let camera = permissions.find(({id}) => id === "camera");
-  Assert.deepEqual(camera, {
-    id: "camera",
-    label: "Use the Camera",
-    state: SitePermissions.ALLOW,
-    availableStates: [
-      { id: SitePermissions.UNKNOWN, label: "Always Ask" },
-      { id: SitePermissions.ALLOW, label: "Allow" },
-      { id: SitePermissions.BLOCK, label: "Block" },
-    ]
-  });
-
-  // check that removed permissions (State.UNKNOWN) are skipped
-  SitePermissions.remove(uri, "camera");
-  permissions = SitePermissions.getPermissionDetailsByURI(uri);
-
-  camera = permissions.find(({id}) => id === "camera");
-  Assert.equal(camera, undefined);
-
-  // check that different available state values are represented
-
-  let cookie = permissions.find(({id}) => id === "cookie");
-  Assert.deepEqual(cookie, {
-    id: "cookie",
-    label: "Set Cookies",
-    state: SitePermissions.SESSION,
-    availableStates: [
-      { id: SitePermissions.ALLOW, label: "Allow" },
-      { id: SitePermissions.SESSION, label: "Allow for Session" },
-      { id: SitePermissions.BLOCK, label: "Block" },
-    ]
-  });
-
-  let popup = permissions.find(({id}) => id === "popup");
-  Assert.deepEqual(popup, {
-    id: "popup",
-    label: "Open Pop-up Windows",
-    state: SitePermissions.BLOCK,
-    availableStates: [
-      { id: SitePermissions.ALLOW, label: "Allow" },
-      { id: SitePermissions.BLOCK, label: "Block" },
-    ]
-  });
-
-  SitePermissions.remove(uri, "cookie");
-  SitePermissions.remove(uri, "popup");
-});
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -15,16 +15,18 @@ Cu.import("resource://gre/modules/Servic
 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.defineLazyModuleGetter(this, "SitePermissions",
+                                  "resource:///modules/SitePermissions.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
   return Services.strings.createBundle("chrome://branding/locale/brand.properties");
 });
 
 this.webrtcUI = {
   peerConnectionBlockers: new Set(),
   emitter: new EventEmitter(),
@@ -331,16 +333,27 @@ function getHost(uri, href) {
   }
   return host;
 }
 
 function prompt(aBrowser, aRequest) {
   let {audioDevices: audioDevices, videoDevices: videoDevices,
        sharingScreen: sharingScreen, sharingAudio: sharingAudio,
        requestTypes: requestTypes} = aRequest;
+
+  // If the user has already denied access once in this tab,
+  // deny again without even showing the notification icon.
+  if ((audioDevices.length && SitePermissions
+        .get(null, "microphone", aBrowser).state == SitePermissions.BLOCK) ||
+      (videoDevices.length && SitePermissions
+        .get(null, sharingScreen ? "screen" : "camera", aBrowser).state == SitePermissions.BLOCK)) {
+    denyRequest(aBrowser, aRequest);
+    return;
+  }
+
   let uri = Services.io.newURI(aRequest.documentURI);
   let host = getHost(uri);
   let chromeDoc = aBrowser.ownerDocument;
   let stringBundle = chromeDoc.defaultView.gNavigatorBundle;
   let stringId = "getUserMedia.share" + requestTypes.join("And") + "2.message";
   let message = stringBundle.getFormattedString(stringId, [host]);
 
   let notification; // Used by action callbacks.
@@ -354,23 +367,26 @@ function prompt(aBrowser, aRequest) {
   };
 
   let secondaryActions = [
     {
       label: stringBundle.getString("getUserMedia.dontAllow.label"),
       accessKey: stringBundle.getString("getUserMedia.dontAllow.accesskey"),
       callback(aState) {
         denyRequest(notification.browser, aRequest);
+        let scope = SitePermissions.SCOPE_TEMPORARY;
         if (aState && aState.checkboxChecked) {
-          let perms = Services.perms;
-          if (audioDevices.length)
-            perms.add(uri, "microphone", perms.DENY_ACTION);
-          if (videoDevices.length)
-            perms.add(uri, sharingScreen ? "screen" : "camera", perms.DENY_ACTION);
+          scope = SitePermissions.SCOPE_PERSISTENT;
         }
+        if (audioDevices.length)
+          SitePermissions.set(uri, "microphone",
+                              SitePermissions.BLOCK, scope, notification.browser);
+        if (videoDevices.length)
+          SitePermissions.set(uri, sharingScreen ? "screen" : "camera",
+                              SitePermissions.BLOCK, scope, notification.browser);
       }
     }
   ];
 
   let productName = gBrandBundle.GetStringFromName("brandShortName");
 
   // Disable the permanent 'Allow' action if the connection isn't secure, or for
   // screen/audio sharing (because we can't guess which window the user wants to
@@ -417,55 +433,49 @@ function prompt(aBrowser, aRequest) {
           menupopup.removeEventListener("command", menupopup._commandEventListener);
           menupopup._commandEventListener = null;
         }
       }
 
       if (aTopic != "showing")
         return false;
 
-      // DENY_ACTION is handled immediately by MediaManager, but handling
-      // of ALLOW_ACTION is delayed until the popupshowing event
+      // BLOCK is handled immediately by MediaManager if it has been set
+      // persistently in the permission manager. If it has been set on the tab,
+      // it is handled synchronously before we add the notification.
+      // Handling of ALLOW is delayed until the popupshowing event,
       // to avoid granting permissions automatically to background tabs.
       if (aRequest.secure) {
-        let perms = Services.perms;
+        let micAllowed =
+          SitePermissions.get(uri, "microphone").state == SitePermissions.ALLOW;
+        let camAllowed =
+          SitePermissions.get(uri, "camera").state == SitePermissions.ALLOW;
 
-        let micPerm = perms.testExactPermission(uri, "microphone");
-        if (micPerm == perms.PROMPT_ACTION)
-          micPerm = perms.UNKNOWN_ACTION;
-
-        let camPerm = perms.testExactPermission(uri, "camera");
-
+        let perms = Services.perms;
         let mediaManagerPerm =
           perms.testExactPermission(uri, "MediaManagerVideo");
         if (mediaManagerPerm) {
           perms.remove(uri, "MediaManagerVideo");
         }
 
-        if (camPerm == perms.PROMPT_ACTION)
-          camPerm = perms.UNKNOWN_ACTION;
-
         // Screen sharing shouldn't follow the camera permissions.
         if (videoDevices.length && sharingScreen)
-          camPerm = perms.UNKNOWN_ACTION;
+          camAllowed = false;
 
-        // We don't check that permissions are set to ALLOW_ACTION in this
-        // test; only that they are set. This is because if audio is allowed
-        // and video is denied persistently, we don't want to show the prompt,
-        // and will grant audio access immediately.
-        if ((!audioDevices.length || micPerm) && (!videoDevices.length || camPerm)) {
+        if ((!audioDevices.length || micAllowed) &&
+            (!videoDevices.length || camAllowed)) {
           // All permissions we were about to request are already persistently set.
           let allowedDevices = [];
-          if (videoDevices.length && camPerm == perms.ALLOW_ACTION) {
+          if (videoDevices.length && camAllowed) {
             allowedDevices.push(videoDevices[0].deviceIndex);
             Services.perms.add(uri, "MediaManagerVideo",
                                Services.perms.ALLOW_ACTION,
                                Services.perms.EXPIRE_SESSION);
           }
-          if (audioDevices.length && micPerm == perms.ALLOW_ACTION)
+          if (audioDevices.length && micAllowed)
             allowedDevices.push(audioDevices[0].deviceIndex);
 
           // Remember on which URIs we found persistent permissions so that we
           // can remove them if the user clicks 'Stop Sharing'. There's no
           // other way for the stop sharing code to know the hostnames of frames
           // using devices until bug 1066082 is fixed.
           let browser = this.browser;
           browser._devicePermissionURIs = browser._devicePermissionURIs || [];
@@ -649,31 +659,34 @@ function prompt(aBrowser, aRequest) {
           let videoDeviceIndex = doc.getElementById(listId).value;
           let allowCamera = videoDeviceIndex != "-1";
           if (allowCamera) {
             allowedDevices.push(videoDeviceIndex);
             // Session permission will be removed after use
             // (it's really one-shot, not for the entire session)
             perms.add(uri, "MediaManagerVideo", perms.ALLOW_ACTION,
                       perms.EXPIRE_SESSION);
-          }
-          if (remember) {
-            perms.add(uri, "camera",
-                      allowCamera ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+            if (remember)
+              SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
+          } else {
+            let scope = remember ? SitePermissions.SCOPE_PERSISTENT : SitePermissions.SCOPE_TEMPORARY;
+            SitePermissions.set(uri, "camera", SitePermissions.BLOCK, scope, aBrowser);
           }
         }
         if (audioDevices.length) {
           if (!sharingAudio) {
             let audioDeviceIndex = doc.getElementById("webRTC-selectMicrophone-menulist").value;
             let allowMic = audioDeviceIndex != "-1";
-            if (allowMic)
+            if (allowMic) {
               allowedDevices.push(audioDeviceIndex);
-            if (remember) {
-              perms.add(uri, "microphone",
-                        allowMic ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+              if (remember)
+                SitePermissions.set(uri, "microphone", SitePermissions.ALLOW);
+            } else {
+                let scope = remember ? SitePermissions.SCOPE_PERSISTENT : SitePermissions.SCOPE_TEMPORARY;
+                SitePermissions.set(uri, "microphone", SitePermissions.BLOCK, scope, aBrowser);
             }
           } else {
             // Only one device possible for audio capture.
             allowedDevices.push(0);
           }
         }
 
         if (!allowedDevices.length) {
--- a/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/ControlCenter.jsm
+++ b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/ControlCenter.jsm
@@ -88,21 +88,22 @@ this.ControlCenter = {
 
         yield loadPage(PERMISSIONS_PAGE);
         yield openIdentityPopup();
       }),
     },
 
     allPermissions: {
       applyConfig: Task.async(function* () {
-        // there are 3 possible non-default permission states, so we alternate between them
-        let states = [SitePermissions.ALLOW, SitePermissions.BLOCK, SitePermissions.SESSION];
+        // TODO: (Bug 1330601) Rewrite this to consider temporary (TAB) permission states.
+        // There are 2 possible non-default permission states, so we alternate between them.
+        let states = [SitePermissions.ALLOW, SitePermissions.BLOCK];
         let uri = Services.io.newURI(PERMISSIONS_PAGE)
         SitePermissions.listPermissions().forEach(function(permission, index) {
-          SitePermissions.set(uri, permission, states[index % 3]);
+          SitePermissions.set(uri, permission, states[index % 2]);
         });
 
         yield loadPage(PERMISSIONS_PAGE);
         yield openIdentityPopup();
       }),
     },
 
     mixed: {
--- a/extensions/cookie/nsPermissionManager.cpp
+++ b/extensions/cookie/nsPermissionManager.cpp
@@ -2015,16 +2015,29 @@ NS_IMETHODIMP
 nsPermissionManager::TestPermissionFromPrincipal(nsIPrincipal* aPrincipal,
                                                  const char* aType,
                                                  uint32_t* aPermission)
 {
   return CommonTestPermission(aPrincipal, aType, aPermission, false, true);
 }
 
 NS_IMETHODIMP
+nsPermissionManager::GetPermissionObjectForURI(nsIURI* aURI,
+                                               const char* aType,
+                                               bool aExactHostMatch,
+                                               nsIPermission** aResult)
+{
+  nsCOMPtr<nsIPrincipal> principal;
+  nsresult rv = GetPrincipal(aURI, getter_AddRefs(principal));
+  NS_ENSURE_SUCCESS(rv, rv);
+
+  return GetPermissionObject(principal, aType, aExactHostMatch, aResult);
+}
+
+NS_IMETHODIMP
 nsPermissionManager::GetPermissionObject(nsIPrincipal* aPrincipal,
                                          const char* aType,
                                          bool aExactHostMatch,
                                          nsIPermission** aResult)
 {
   NS_ENSURE_ARG_POINTER(aPrincipal);
   NS_ENSURE_ARG_POINTER(aType);
 
--- a/netwerk/base/nsIPermissionManager.idl
+++ b/netwerk/base/nsIPermissionManager.idl
@@ -204,16 +204,33 @@ interface nsIPermissionManager : nsISupp
    * @param type      a case-sensitive ASCII string, identifying the consumer
    * @param return    see add(), param permission. returns UNKNOWN_ACTION when
    *                  there is no stored permission for this uri and / or type.
    */
   uint32_t testExactPermanentPermission(in nsIPrincipal principal,
                                         in string type);
 
   /**
+   * Get the permission object associated with the given URI and action.
+   * @param uri The URI
+   * @param type      A case-sensitive ASCII string identifying the consumer
+   * @param exactHost If true, only the specific host will be matched,
+   *                  @see testExactPermission. If false, subdomains will
+   *                  also be searched, @see testPermission.
+   * @returns The matching permission object, or null if no matching object
+   *          was found. No matching object is equivalent to UNKNOWN_ACTION.
+   * @note Clients in general should prefer the test* methods unless they
+   *       need to know the specific stored details.
+   * @note This method will always return null for the system principal.
+   */
+  nsIPermission getPermissionObjectForURI(in nsIURI uri,
+                                          in string type,
+                                          in boolean exactHost);
+
+  /**
    * Get the permission object associated with the given principal and action.
    * @param principal The principal
    * @param type      A case-sensitive ASCII string identifying the consumer
    * @param exactHost If true, only the specific host will be matched,
    *                  @see testExactPermission. If false, subdomains will
    *                  also be searched, @see testPermission.
    * @returns The matching permission object, or null if no matching object
    *          was found. No matching object is equivalent to UNKNOWN_ACTION.