From 2ca010d058998c7adbf0969759fd43755adee59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Desr=C3=A9?= Date: Fri, 18 Jan 2013 09:54:06 -0800 Subject: [PATCH] Bug 829934 - Implement a hash verifier for webapp/mini manifest r=ferjm --- dom/apps/src/AppsUtils.jsm | 2 + dom/apps/src/Webapps.jsm | 389 +++++++++++++++++++++++-------------- 2 files changed, 248 insertions(+), 143 deletions(-) diff --git a/dom/apps/src/AppsUtils.jsm b/dom/apps/src/AppsUtils.jsm index 6caabd29d2a0..b42c9f8ac256 100644 --- a/dom/apps/src/AppsUtils.jsm +++ b/dom/apps/src/AppsUtils.jsm @@ -58,6 +58,8 @@ this.AppsUtils = { updateTime: aApp.updateTime, etag: aApp.etag, packageEtag: aApp.packageEtag, + manifestHash: aApp.manifestHash, + packageHash: aApp.packageHash, installerAppId: aApp.installerAppId || Ci.nsIScriptSecurityManager.NO_APP_ID, installerIsBrowser: !!aApp.installerIsBrowser }; diff --git a/dom/apps/src/Webapps.jsm b/dom/apps/src/Webapps.jsm index 0c2ba30c76cb..6b7ac6a271af 100644 --- a/dom/apps/src/Webapps.jsm +++ b/dom/apps/src/Webapps.jsm @@ -1124,6 +1124,77 @@ this.DOMApplicationRegistry = { } }, + // Returns the MD5 hash of a file, doing async IO off the main thread. + computeFileHash: function computeFileHash(aFile, aCallback) { + Cu.import("resource://gre/modules/osfile.jsm"); + const CHUNK_SIZE = 16384; + + // Return the two-digit hexadecimal code for a byte. + function toHexString(charCode) { + return ("0" + charCode.toString(16)).slice(-2); + } + + let hasher = Cc["@mozilla.org/security/hash;1"] + .createInstance(Ci.nsICryptoHash); + // We want to use the MD5 algorithm. + hasher.init(hasher.MD5); + + OS.File.open(aFile.path, { read: true }).then( + function opened(file) { + let readChunk = function readChunk() { + file.read(CHUNK_SIZE).then( + function readSuccess(array) { + hasher.update(array, array.length); + if (array.length == CHUNK_SIZE) { + readChunk(); + } else { + // We're passing false to get the binary hash and not base64. + let hash = hasher.finish(false); + // convert the binary hash data to a hex string. + aCallback([toHexString(hash.charCodeAt(i)) for (i in hash)] + .join("")); + } + }, + function readError() { + debug("Error reading " + aFile.path); + aCallback(null); + } + ); + } + + readChunk(); + }, + function openError() { + debug("Error opening " + aFile.path); + aCallback(null); + } + ); + }, + + // Returns the MD5 hash of the manifest. + computeManifestHash: function(aManifest) { + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let result = {}; + // Data is an array of bytes. + let data = converter.convertToByteArray(JSON.stringify(aManifest), result); + + let hasher = Cc["@mozilla.org/security/hash;1"] + .createInstance(Ci.nsICryptoHash); + hasher.init(hasher.MD5); + hasher.update(data, data.length); + // We're passing false to get the binary hash and not base64. + let hash = hasher.finish(false); + + function toHexString(charCode) { + return ("0" + charCode.toString(16)).slice(-2); + } + + // Convert the binary hash data to a hex string. + return [toHexString(hash.charCodeAt(i)) for (i in hash)].join(""); + }, + checkForUpdate: function(aData, aMm) { debug("checkForUpdate for " + aData.manifestURL); let id = this._appIdForManifestURL(aData.manifestURL); @@ -1336,12 +1407,15 @@ this.DOMApplicationRegistry = { xhr.addEventListener("load", (function() { debug("Got http status=" + xhr.status + " for " + aData.manifestURL); + let oldHash = app.manifestHash; + if (xhr.status == 200) { let manifest = xhr.response; if (manifest == null) { sendError("MANIFEST_PARSE_ERROR"); return; } + if (!AppsUtils.checkManifest(manifest, app)) { sendError("INVALID_MANIFEST"); return; @@ -1349,14 +1423,33 @@ this.DOMApplicationRegistry = { sendError("INSTALL_FROM_DENIED"); return; } else { + let hash = this.computeManifestHash(manifest); + debug("Manifest hash = " + hash); + app.manifestHash = hash; + app.etag = xhr.getResponseHeader("Etag"); debug("at update got app etag=" + app.etag); app.lastCheckedUpdate = Date.now(); if (app.origin.startsWith("app://")) { - updatePackagedApp.call(this, manifest); + if (oldHash != hash) { + updatePackagedApp.call(this, manifest); + } else { + // Like if we got a 304, just send a 'downloadapplied' + // or downloadavailable event. + aData.event = app.downloadAvailable ? "downloadavailable" + : "downloadapplied"; + aData.app = { + lastCheckedUpdate: app.lastCheckedUpdate + } + aMm.sendAsyncMessage("Webapps:CheckForUpdate:Return:OK", aData); + this._saveApps(); + } } else { this._readManifests([{ id: id }], (function(aResult) { - updateHostedApp.call(this, aResult[0].manifest, manifest); + // Update only the appcache if the manifest has not changed + // based on the hash value. + updateHostedApp.call(this, aResult[0].manifest, + oldHash == hash ? null : manifest); }).bind(this)); } } @@ -1364,7 +1457,7 @@ this.DOMApplicationRegistry = { // The manifest has not changed. if (app.origin.startsWith("app://")) { // If the app is a packaged app, we just send a 'downloadapplied' - // event. + // or downloadavailable event. app.lastCheckedUpdate = Date.now(); aData.event = app.downloadAvailable ? "downloadavailable" : "downloadapplied"; @@ -1467,6 +1560,7 @@ this.DOMApplicationRegistry = { sendError("INVALID_SECURITY_LEVEL"); } else { app.etag = xhr.getResponseHeader("Etag"); + app.manifestHash = this.computeManifestHash(app.manifest); // We allow bypassing the install confirmation process to facilitate // automation. let prefName = "dom.mozApps.auto_confirm_install"; @@ -1521,6 +1615,7 @@ this.DOMApplicationRegistry = { sendError("MANIFEST_PARSE_ERROR"); return; } + if (!(AppsUtils.checkManifest(manifest, app) && manifest.package_path)) { sendError("INVALID_MANIFEST"); @@ -1528,6 +1623,7 @@ this.DOMApplicationRegistry = { sendError("INSTALL_FROM_DENIED"); } else { app.etag = xhr.getResponseHeader("Etag"); + app.manifestHash = this.computeManifestHash(manifest); debug("at install package got app etag=" + app.etag); Services.obs.notifyObservers(aMm, "webapps-ask-install", JSON.stringify(aData)); @@ -1952,151 +2048,158 @@ this.DOMApplicationRegistry = { return; } - if (requestChannel.responseStatus == 304) { - // The package's Etag has not changed. - // We send a "applied" event right away. - app.downloading = false; - app.downloadAvailable = false; - app.downloadSize = 0; - app.installState = "installed"; - app.readyToApplyDownload = false; - self.broadcastMessage("Webapps:PackageEvent", { - type: "applied", - manifestURL: aApp.manifestURL, - app: app }); - // Save the updated registry, and cleanup the tmp directory. - self._saveApps(); - let file = FileUtils.getFile("TmpD", ["webapps", id], false); - if (file && file.exists()) { - file.remove(true); + self.computeFileHash(zipFile, function onHashComputed(aHash) { + debug("packageHash=" + aHash); + let newPackage = (requestChannel.responseStatus != 304) && + (aHash != app.packageHash); + + if (!newPackage) { + // The package's Etag or hash has not changed. + // We send a "applied" event right away. + app.downloading = false; + app.downloadAvailable = false; + app.downloadSize = 0; + app.installState = "installed"; + app.readyToApplyDownload = false; + self.broadcastMessage("Webapps:PackageEvent", { + type: "applied", + manifestURL: aApp.manifestURL, + app: app }); + // Save the updated registry, and cleanup the tmp directory. + self._saveApps(); + let file = FileUtils.getFile("TmpD", ["webapps", id], false); + if (file && file.exists()) { + file.remove(true); + } + return; } - return; - } - let certdb; - try { - certdb = Cc["@mozilla.org/security/x509certdb;1"] - .getService(Ci.nsIX509CertDB); - } catch (e) { - cleanup("CERTDB_ERROR"); - return; - } - - certdb.openSignedJARFileAsync(zipFile, function(aRv, aZipReader) { - let zipReader; + let certdb; try { - let isSigned; - if (Components.isSuccessCode(aRv)) { - isSigned = true; - zipReader = aZipReader; - } else if (aRv != Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED) { - throw "INVALID_SIGNATURE"; - } else { - isSigned = false; - zipReader = Cc["@mozilla.org/libjar/zip-reader;1"] - .createInstance(Ci.nsIZipReader); - zipReader.open(zipFile); - } - - // XXX Security: You CANNOT safely add a new app store for - // installing privileged apps just by modifying this pref and - // adding the signing cert for that store to the cert trust - // database. *Any* origin listed can install apps signed with - // *any* certificate trusted; we don't try to maintain a strong - // association between certificate with installOrign. The - // expectation here is that in production builds the pref will - // contain exactly one origin. However, in custom development - // builds it may contain more than one origin so we can test - // different stages (dev, staging, prod) of the same app store. - // - // Only allow signed apps to be installed from a whitelist of - // domains, and require all packages installed from any of the - // domains on the whitelist to be signed. This is a stopgap until - // we have a real story for handling multiple app stores signing - // apps. - let signedAppOriginsStr = - Services.prefs.getCharPref( - "dom.mozApps.signed_apps_installable_from"); - let isSignedAppOrigin - = signedAppOriginsStr.split(",").indexOf(aApp.installOrigin) > -1; - if (!isSigned && isSignedAppOrigin) { - // Packaged apps installed from these origins must be signed; - // if not, assume somebody stripped the signature. - throw "INVALID_SIGNATURE"; - } else if (isSigned && !isSignedAppOrigin) { - // Other origins are *prohibited* from installing signed apps. - // One reason is that our app revociation mechanism requires - // strong cooperation from the host of the mini-manifest, which - // we assume to be under the control of the install origin, - // even if it has a different origin. - throw "INSTALL_FROM_DENIED"; - } - - if (!zipReader.hasEntry("manifest.webapp")) { - throw "MISSING_MANIFEST"; - } - - let istream = zipReader.getInputStream("manifest.webapp"); - - // Obtain a converter to read from a UTF-8 encoded input stream. - let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] - .createInstance(Ci.nsIScriptableUnicodeConverter); - converter.charset = "UTF-8"; - - let manifest = JSON.parse(converter.ConvertToUnicode(NetUtil.readInputStreamToString(istream, - istream.available()) || "")); - - // Call checkManifest before compareManifests, as checkManifest - // will normalize some attributes that has already been normalized - // for aManifest during checkForUpdate. - if (!AppsUtils.checkManifest(manifest, app)) { - throw "INVALID_MANIFEST"; - } - - if (!AppsUtils.compareManifests(manifest, - aManifest._manifest)) { - throw "MANIFEST_MISMATCH"; - } - - if (!AppsUtils.checkInstallAllowed(manifest, aApp.installOrigin)) { - throw "INSTALL_FROM_DENIED"; - } - - let maxStatus = isSigned ? Ci.nsIPrincipal.APP_STATUS_PRIVILEGED - : Ci.nsIPrincipal.APP_STATUS_INSTALLED; - - if (AppsUtils.getAppManifestStatus(manifest) > maxStatus) { - throw "INVALID_SECURITY_LEVEL"; - } - aApp.appStatus = AppsUtils.getAppManifestStatus(manifest); - // Save the new Etag for the package. - try { - app.packageEtag = requestChannel.getResponseHeader("Etag"); - debug("Package etag=" + app.packageEtag); - } catch (e) { - // in https://bugzilla.mozilla.org/show_bug.cgi?id=825218 - // we'll fail gracefully in this case - // for now, just going on - app.packageEtag = null; - debug("Can't find an etag, this should not happen"); - } - - if (aOnSuccess) { - aOnSuccess(id, manifest); - } + certdb = Cc["@mozilla.org/security/x509certdb;1"] + .getService(Ci.nsIX509CertDB); } catch (e) { - // Something bad happened when reading the package. - if (typeof e == 'object') { - Cu.reportError("Error while reading package:" + e); - cleanup("INVALID_PACKAGE"); - } else { - cleanup(e); - } - } finally { - AppDownloadManager.remove(aApp.manifestURL); - if (zipReader) - zipReader.close(); + cleanup("CERTDB_ERROR"); + return; } + + certdb.openSignedJARFileAsync(zipFile, function(aRv, aZipReader) { + let zipReader; + try { + let isSigned; + if (Components.isSuccessCode(aRv)) { + isSigned = true; + zipReader = aZipReader; + } else if (aRv != Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED) { + throw "INVALID_SIGNATURE"; + } else { + isSigned = false; + zipReader = Cc["@mozilla.org/libjar/zip-reader;1"] + .createInstance(Ci.nsIZipReader); + zipReader.open(zipFile); + } + + // XXX Security: You CANNOT safely add a new app store for + // installing privileged apps just by modifying this pref and + // adding the signing cert for that store to the cert trust + // database. *Any* origin listed can install apps signed with + // *any* certificate trusted; we don't try to maintain a strong + // association between certificate with installOrign. The + // expectation here is that in production builds the pref will + // contain exactly one origin. However, in custom development + // builds it may contain more than one origin so we can test + // different stages (dev, staging, prod) of the same app store. + // + // Only allow signed apps to be installed from a whitelist of + // domains, and require all packages installed from any of the + // domains on the whitelist to be signed. This is a stopgap until + // we have a real story for handling multiple app stores signing + // apps. + let signedAppOriginsStr = + Services.prefs.getCharPref( + "dom.mozApps.signed_apps_installable_from"); + let isSignedAppOrigin + = signedAppOriginsStr.split(",").indexOf(aApp.installOrigin) > -1; + if (!isSigned && isSignedAppOrigin) { + // Packaged apps installed from these origins must be signed; + // if not, assume somebody stripped the signature. + throw "INVALID_SIGNATURE"; + } else if (isSigned && !isSignedAppOrigin) { + // Other origins are *prohibited* from installing signed apps. + // One reason is that our app revociation mechanism requires + // strong cooperation from the host of the mini-manifest, which + // we assume to be under the control of the install origin, + // even if it has a different origin. + throw "INSTALL_FROM_DENIED"; + } + + if (!zipReader.hasEntry("manifest.webapp")) { + throw "MISSING_MANIFEST"; + } + + let istream = zipReader.getInputStream("manifest.webapp"); + + // Obtain a converter to read from a UTF-8 encoded input stream. + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + + let manifest = JSON.parse(converter.ConvertToUnicode(NetUtil.readInputStreamToString(istream, + istream.available()) || "")); + + // Call checkManifest before compareManifests, as checkManifest + // will normalize some attributes that has already been normalized + // for aManifest during checkForUpdate. + if (!AppsUtils.checkManifest(manifest, app)) { + throw "INVALID_MANIFEST"; + } + + if (!AppsUtils.compareManifests(manifest, + aManifest._manifest)) { + throw "MANIFEST_MISMATCH"; + } + + if (!AppsUtils.checkInstallAllowed(manifest, aApp.installOrigin)) { + throw "INSTALL_FROM_DENIED"; + } + + let maxStatus = isSigned ? Ci.nsIPrincipal.APP_STATUS_PRIVILEGED + : Ci.nsIPrincipal.APP_STATUS_INSTALLED; + + if (AppsUtils.getAppManifestStatus(manifest) > maxStatus) { + throw "INVALID_SECURITY_LEVEL"; + } + app.appStatus = AppsUtils.getAppManifestStatus(manifest); + app.packageHash = aHash; + // Save the new Etag for the package. + try { + app.packageEtag = requestChannel.getResponseHeader("Etag"); + debug("Package etag=" + app.packageEtag); + } catch (e) { + // in https://bugzilla.mozilla.org/show_bug.cgi?id=825218 + // we'll fail gracefully in this case + // for now, just going on + app.packageEtag = null; + debug("Can't find an etag, this should not happen"); + } + + if (aOnSuccess) { + aOnSuccess(id, manifest); + } + } catch (e) { + // Something bad happened when reading the package. + if (typeof e == 'object') { + Cu.reportError("Error while reading package:" + e); + cleanup("INVALID_PACKAGE"); + } else { + cleanup(e); + } + } finally { + AppDownloadManager.remove(aApp.manifestURL); + if (zipReader) + zipReader.close(); + } + }); }); } });