Bug 1077168 - Cancel in-flight Webapp install jobs from windows that change location. r=myk.

Installing a Webapp is an asynchronous job, and there is a pocket of time
between when web content requests to install an app and before the browser
displays an installation prompt that the outer window of the content can
browse away. This pocket of time is typically used by XHR to request the
web app resources and verify their contents.

This pocket of time is, essentially, bug 771294, and is a bit of a security
problem.

This problem was originally patched over on Desktop by checking in the parent
process that the outer window was still at the same URI as it had been when it
made the request. I'm not entirely sure if Android / B2G made similar checks.

With separated content processes, however, the browser front-end can no longer
performantly check to ensure that the outer window is at the same URI.

We solve this problem by sending up a message in the content process when
the location of an outer window making use of navigator.mozApps changes.
We hold a Map of "actions" mapping to in-flight installs mapped by the
outer window ID of the requesting content. When we notice a location
change, we mark those actions as cancelled. When the XHR returns, we have
it check the state of its actions, and if they're cancelled, it aborts
further action.

Normally, this wouldn't be necessary, since any XHR initiated by the
content window would be cancelled once the location changed, but in
this case, the XHR is occurring in Webapps.jsm, and is not influenced
by the outer window of the content.
This commit is contained in:
Mike Conley 2015-02-10 13:18:47 -05:00
parent 86cf179c70
commit 3e118befc1
4 changed files with 135 additions and 48 deletions

View File

@ -267,6 +267,7 @@ WebappsRegistry.prototype = {
cpmm.sendAsyncMessage("Webapps:UnregisterForMessages",
["Webapps:Install:Return:OK",
"Webapps:AdditionalLanguageChange"]);
this._window.removeEventListener("pagehide", this);
},
installPackage: function(aURL, aParams) {
@ -337,6 +338,7 @@ WebappsRegistry.prototype = {
const prefs = new Preferences();
this._window = aWindow;
this._window.addEventListener("pagehide", this);
this.initDOMRequestHelper(aWindow, ["Webapps:Install:Return:OK",
"Webapps:AdditionalLanguageChange"]);
@ -375,6 +377,15 @@ WebappsRegistry.prototype = {
this.hasFullMgmtPrivilege = hasWebappsPermission;
},
handleEvent(event) {
if (event.type == "pagehide" &&
event.target.defaultView == this._window) {
cpmm.sendAsyncMessage("Webapps:LocationChange", {
oid: this._id,
});
}
},
classID: Components.ID("{fff440b3-fae2-45c1-bf03-3b5a2e432270}"),
QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference,

View File

@ -199,6 +199,7 @@ this.DOMApplicationRegistry = {
allAppsLaunchable: false,
_updateHandlers: [ ],
_pendingUninstalls: {},
_contentActions: new Map(),
dirKey: DIRECTORY_NAME,
init: function() {
@ -209,6 +210,7 @@ this.DOMApplicationRegistry = {
"Webapps:GetInstalled",
"Webapps:GetNotInstalled",
"Webapps:Launch",
"Webapps:LocationChange",
"Webapps:InstallPackage",
"Webapps:GetList",
"Webapps:RegisterForMessages",
@ -1385,6 +1387,9 @@ this.DOMApplicationRegistry = {
case "Webapps:Launch":
this.doLaunch(msg, mm);
break;
case "Webapps:LocationChange":
this.onLocationChange(msg.oid);
break;
case "Webapps:CheckInstalled":
this.checkInstalled(msg, mm);
break;
@ -2525,6 +2530,7 @@ this.DOMApplicationRegistry = {
aMm.sendAsyncMessage("Webapps:Install:Return:KO", aData);
Cu.reportError("Error installing app from: " + app.installOrigin +
": " + aError);
this.popContentAction(aData.oid);
}.bind(this);
if (app.receipts.length > 0) {
@ -2586,18 +2592,28 @@ this.DOMApplicationRegistry = {
let installApp = (function() {
app.manifestHash = this.computeManifestHash(app.manifest);
// We allow bypassing the install confirmation process to facilitate
// automation.
let prefName = "dom.mozApps.auto_confirm_install";
if (Services.prefs.prefHasUserValue(prefName) &&
Services.prefs.getBoolPref(prefName)) {
this.confirmInstall(aData);
} else {
Services.obs.notifyObservers(aMm, "webapps-ask-install",
JSON.stringify(aData));
// Check to see if the action has been cancelled in the interim.
let cancelled = this.actionCancelled(aData.oid);
this.popContentAction(aData.oid);
if (!cancelled) {
// We allow bypassing the install confirmation process to facilitate
// automation.
let prefName = "dom.mozApps.auto_confirm_install";
if (Services.prefs.prefHasUserValue(prefName) &&
Services.prefs.getBoolPref(prefName)) {
this.confirmInstall(aData);
} else {
Services.obs.notifyObservers(aMm, "webapps-ask-install",
JSON.stringify(aData));
}
}
}).bind(this);
// This action will be popped on success in installApp, or on
// failure in sendError.
this.pushContentAction(aData.oid);
// We may already have the manifest (e.g. AutoInstall),
// in which case we don't need to load it.
if (app.manifest) {
@ -2669,6 +2685,7 @@ this.DOMApplicationRegistry = {
aMm.sendAsyncMessage("Webapps:Install:Return:KO", aData);
Cu.reportError("Error installing packaged app from: " +
app.installOrigin + ": " + aError);
this.popContentAction(aData.oid);
}.bind(this);
if (app.receipts.length > 0) {
@ -2707,18 +2724,27 @@ this.DOMApplicationRegistry = {
let installApp = (function() {
app.manifestHash = this.computeManifestHash(app.updateManifest);
// We allow bypassing the install confirmation process to facilitate
// automation.
let prefName = "dom.mozApps.auto_confirm_install";
if (Services.prefs.prefHasUserValue(prefName) &&
Services.prefs.getBoolPref(prefName)) {
this.confirmInstall(aData);
} else {
Services.obs.notifyObservers(aMm, "webapps-ask-install",
JSON.stringify(aData));
// Check to see if the action has been cancelled in the interim.
let cancelled = this.actionCancelled(aData.oid);
this.popContentAction(aData.oid);
if (!cancelled) {
// We allow bypassing the install confirmation process to facilitate
// automation.
let prefName = "dom.mozApps.auto_confirm_install";
if (Services.prefs.prefHasUserValue(prefName) &&
Services.prefs.getBoolPref(prefName)) {
this.confirmInstall(aData);
} else {
Services.obs.notifyObservers(aMm, "webapps-ask-install",
JSON.stringify(aData));
}
}
}).bind(this);
// This action will be popped on success in installApp, or on
// failure in sendError.
this.pushContentAction(aData.oid);
// We may already have the manifest (e.g. AutoInstall),
// in which case we don't need to load it.
if (app.updateManifest) {
@ -2767,6 +2793,42 @@ this.DOMApplicationRegistry = {
xhr.send(null);
},
onLocationChange(oid) {
let action = this._contentActions.get(oid);
if (action) {
action.cancelled = true;
}
},
pushContentAction: function(windowID) {
let actions = this._contentActions.get(windowID);
if (!actions) {
actions = {
count: 0,
cancelled: false,
};
this._contentActions.set(windowID, actions);
}
actions.count++;
},
popContentAction: function(windowID) {
let actions = this._contentActions.get(windowID);
if (!actions) {
Cu.reportError(`Failed to pop content action for window with ID ${windowID}`);
return;
}
actions.count--;
if (!actions.count) {
this._contentActions.delete(windowID);
}
},
actionCancelled: function(windowID) {
return this._contentActions.has(windowID) &&
this._contentActions.get(windowID).cancelled;
},
denyInstall: function(aData) {
let packageId = aData.app.packageId;
if (packageId) {

View File

@ -54,6 +54,38 @@ function confirmNextPopup() {
popupPanel.addEventListener("popupshown", onPopupShown, false);
}
function promiseNoPopup() {
var Ci = SpecialPowers.Ci;
var popupNotifications = SpecialPowers.wrap(window).top.
QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIWebNavigation).
QueryInterface(Ci.nsIDocShell).
chromeEventHandler.ownerDocument.defaultView.
PopupNotifications;
return new Promise((resolve) => {
var tries = 0;
var interval = setInterval(function() {
if (tries >= 30) {
ok(true, "The webapps-install notification didn't appear");
moveOn();
}
if (popupNotifications.getNotification("webapps-install")) {
ok(false, "Found the webapps-install notification");
moveOn();
}
tries++;
}, 100);
var moveOn = () => {
clearInterval(interval);
resolve();
};
});
}
// We need to mock the Alerts service, otherwise the alert that is shown
// at the end of an installation makes the test leak the app's icon.

View File

@ -24,40 +24,22 @@ SpecialPowers.setAllAppsLaunchable(true);
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/PopupNotifications.jsm");
let blocked = true;
function blockedListener() {
blocked = false;
}
let panel = window.top.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShell)
.chromeEventHandler.ownerDocument.defaultView
.PopupNotifications.panel;
panel.addEventListener("popupshowing", blockedListener, false);
Services.obs.addObserver(
function observeInstalling() {
Services.obs.removeObserver(observeInstalling, "webapps-ask-install");
// Spin the event loop before running the test to give the registry time
// to process the install request and (hopefully not) show the doorhanger.
setTimeout(function verify() {
ok(blocked, "Install panel was blocked after immediate redirect");
panel.removeEventListener("popupshowing", blockedListener);
SimpleTest.finish();
}, 0);
},
"webapps-ask-install",
false
);
let PopupNotifications = window.top.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShell)
.chromeEventHandler.ownerDocument.defaultView
.PopupNotifications;
addEventListener("DOMContentLoaded", () => {
let iframe = document.getElementById("iframe");
iframe.addEventListener("load", function(e) {
promiseNoPopup().then(SimpleTest.finish);
});
});
</script>
<!-- Load a page that initiates an app installation and then immediately
- redirects to a page at a different origin. We can't do this directly
- inside this test page, because that would cause the test to hang. -->
<iframe src="http://test/chrome/dom/tests/mochitest/webapps/install_and_redirect_helper.xul"/>
<iframe id="iframe" src="http://test/chrome/dom/tests/mochitest/webapps/install_and_redirect_helper.xul"/>
</window>