diff options
Diffstat (limited to 'toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm')
-rw-r--r-- | toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm | 397 |
1 files changed, 397 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm b/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm new file mode 100644 index 0000000000..8b7dc62c7c --- /dev/null +++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm @@ -0,0 +1,397 @@ +/* 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"; + +/* exported ProductAddonChecker */ + +var EXPORTED_SYMBOLS = ["ProductAddonChecker"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { CertUtils } = ChromeUtils.import( + "resource://gre/modules/CertUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]); + +// This exists so that tests can override the XHR behaviour for downloading +// the addon update XML file. +var CreateXHR = function() { + return new XMLHttpRequest(); +}; + +var logger = Log.repository.getLogger("addons.productaddons"); + +/** + * Number of milliseconds after which we need to cancel `downloadXML`. + * + * Bug 1087674 suggests that the XHR we use in `downloadXML` may + * never terminate in presence of network nuisances (e.g. strange + * antivirus behavior). This timeout is a defensive measure to ensure + * that we fail cleanly in such case. + */ +const TIMEOUT_DELAY_MS = 20000; +// How much of a file to read into memory at a time for hashing +const HASH_CHUNK_SIZE = 8192; + +/** + * Gets the status of an XMLHttpRequest either directly or from its underlying + * channel. + * + * @param request + * The XMLHttpRequest. + * @return an integer status value. + */ +function getRequestStatus(request) { + let status = null; + try { + status = request.status; + } catch (e) {} + + if (status != null) { + return status; + } + + return request.channel.QueryInterface(Ci.nsIRequest).status; +} + +/** + * Downloads an XML document from a URL optionally testing the SSL certificate + * for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to the DOM document downloaded or rejects + * with a JS exception in case of error. + */ +function downloadXML(url, allowNonBuiltIn = false, allowedCerts = null) { + return new Promise((resolve, reject) => { + let request = CreateXHR(); + // This is here to let unit test code override XHR + if (request.wrappedJSObject) { + request = request.wrappedJSObject; + } + request.open("GET", url, true); + request.channel.notificationCallbacks = new CertUtils.BadCertHandler( + allowNonBuiltIn + ); + // Prevent the request from reading from the cache. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // Don't send any cookies + request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS; + // Use conservative TLS settings. See bug 1325501. + // TODO move to ServiceRequest. + if (request.channel instanceof Ci.nsIHttpChannelInternal) { + request.channel.QueryInterface( + Ci.nsIHttpChannelInternal + ).beConservative = true; + } + request.timeout = TIMEOUT_DELAY_MS; + + request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + request.setRequestHeader("Pragma", "no-cache"); + + let fail = event => { + let request = event.target; + let status = getRequestStatus(request); + let message = + "Failed downloading XML, status: " + status + ", reason: " + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status; + reject(ex); + }; + + let success = event => { + logger.info("Completed downloading document"); + let request = event.target; + + try { + CertUtils.checkCert(request.channel, allowNonBuiltIn, allowedCerts); + } catch (ex) { + logger.error("Request failed certificate checks: " + ex); + ex.status = getRequestStatus(request); + reject(ex); + return; + } + + resolve(request.responseXML); + }; + + request.addEventListener("error", fail); + request.addEventListener("abort", fail); + request.addEventListener("timeout", fail); + request.addEventListener("load", success); + + logger.info("sending request to: " + url); + request.send(null); + }); +} + +/** + * Parses a list of add-ons from a DOM document. + * + * @param document + * The DOM document to parse. + * @return null if there is no <addons> element otherwise an object containing + * an array of the addons listed and a field notifying whether the + * fallback was used. + */ +function parseXML(document) { + // Check that the root element is correct + if (document.documentElement.localName != "updates") { + throw new Error( + "got node name: " + + document.documentElement.localName + + ", expected: updates" + ); + } + + // Check if there are any addons elements in the updates element + let addons = document.querySelector("updates:root > addons"); + if (!addons) { + return null; + } + + let results = []; + let addonList = document.querySelectorAll("updates:root > addons > addon"); + for (let addonElement of addonList) { + let addon = {}; + + for (let name of [ + "id", + "URL", + "hashFunction", + "hashValue", + "version", + "size", + ]) { + if (addonElement.hasAttribute(name)) { + addon[name] = addonElement.getAttribute(name); + } + } + addon.size = Number(addon.size) || undefined; + + results.push(addon); + } + + return { + usedFallback: false, + addons: results, + }; +} + +/** + * Downloads file from a URL using XHR. + * + * @param url + * The url to download from. + * @param options (optional) + * @param options.httpsOnlyNoUpgrade + * Prevents upgrade to https:// when HTTPS-Only Mode is enabled. + * @return a promise that resolves to the path of a temporary file or rejects + * with a JS exception in case of error. + */ +function downloadFile(url, options = { httpsOnlyNoUpgrade: false }) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + + xhr.onload = function(response) { + logger.info("downloadXHR File download. status=" + xhr.status); + if (xhr.status != 200 && xhr.status != 206) { + reject(Components.Exception("File download failed", xhr.status)); + return; + } + (async function() { + let f = await OS.File.openUnique( + OS.Path.join(OS.Constants.Path.tmpDir, "tmpaddon") + ); + let path = f.path; + logger.info(`Downloaded file will be saved to ${path}`); + await f.file.close(); + await OS.File.writeAtomic(path, new Uint8Array(xhr.response)); + return path; + })().then(resolve, reject); + }; + + let fail = event => { + let request = event.target; + let status = getRequestStatus(request); + let message = + "Failed downloading via XHR, status: " + + status + + ", reason: " + + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status; + reject(ex); + }; + xhr.addEventListener("error", fail); + xhr.addEventListener("abort", fail); + + xhr.responseType = "arraybuffer"; + try { + xhr.open("GET", url); + if (options.httpsOnlyNoUpgrade) { + xhr.channel.loadInfo.httpsOnlyStatus |= + Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT; + } + // Allow deprecated HTTP request from SystemPrincipal + xhr.channel.loadInfo.allowDeprecatedSystemRequests = true; + // Use conservative TLS settings. See bug 1325501. + // TODO move to ServiceRequest. + if (xhr.channel instanceof Ci.nsIHttpChannelInternal) { + xhr.channel.QueryInterface( + Ci.nsIHttpChannelInternal + ).beConservative = true; + } + xhr.send(null); + } catch (ex) { + reject(ex); + } + }); +} + +/** + * Convert a string containing binary values to hex. + */ +function binaryToHex(input) { + let result = ""; + for (let i = 0; i < input.length; ++i) { + let hex = input.charCodeAt(i).toString(16); + if (hex.length == 1) { + hex = "0" + hex; + } + result += hex; + } + return result; +} + +/** + * Calculates the hash of a file. + * + * @param hashFunction + * The type of hash function to use, must be supported by nsICryptoHash. + * @param path + * The path of the file to hash. + * @return a promise that resolves to hash of the file or rejects with a JS + * exception in case of error. + */ +var computeHash = async function(hashFunction, path) { + let file = await OS.File.open(path, { existing: true, read: true }); + try { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.initWithString(hashFunction); + + let bytes; + do { + bytes = await file.read(HASH_CHUNK_SIZE); + hasher.update(bytes, bytes.length); + } while (bytes.length == HASH_CHUNK_SIZE); + + return binaryToHex(hasher.finish(false)); + } finally { + await file.close(); + } +}; + +/** + * Verifies that a downloaded file matches what was expected. + * + * @param properties + * The properties to check, `hashFunction` with `hashValue` + * are supported. Any properties missing won't be checked. + * @param path + * The path of the file to check. + * @return a promise that resolves if the file matched or rejects with a JS + * exception in case of error. + */ +var verifyFile = async function(properties, path) { + if (properties.size !== undefined) { + let stat = await OS.File.stat(path); + if (stat.size != properties.size) { + throw new Error( + "Downloaded file was " + + stat.size + + " bytes but expected " + + properties.size + + " bytes." + ); + } + } + + if (properties.hashFunction !== undefined) { + let expectedDigest = properties.hashValue.toLowerCase(); + let digest = await computeHash(properties.hashFunction, path); + if (digest != expectedDigest) { + throw new Error( + "Hash was `" + digest + "` but expected `" + expectedDigest + "`." + ); + } + } +}; + +const ProductAddonChecker = { + /** + * Downloads a list of add-ons from a URL optionally testing the SSL + * certificate for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to an object containing the list of add-ons + * and whether the local fallback was used, or rejects with a JS + * exception in case of error. + */ + getProductAddonList(url, allowNonBuiltIn = false, allowedCerts = null) { + return downloadXML(url, allowNonBuiltIn, allowedCerts).then(parseXML); + }, + + /** + * Downloads an add-on to a local file and checks that it matches the expected + * file. The caller is responsible for deleting the temporary file returned. + * + * @param addon + * The addon to download. + * @param options (optional) + * @param options.httpsOnlyNoUpgrade + * Prevents upgrade to https:// when HTTPS-Only Mode is enabled. + * @return a promise that resolves to the temporary file downloaded or rejects + * with a JS exception in case of error. + */ + async downloadAddon(addon, options = { httpsOnlyNoUpgrade: false }) { + let path = await downloadFile(addon.URL, options); + try { + await verifyFile(addon, path); + return path; + } catch (e) { + await OS.File.remove(path); + throw e; + } + }, +}; |