summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs')
-rw-r--r--toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs601
1 files changed, 601 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
new file mode 100644
index 0000000000..1615a551c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
@@ -0,0 +1,601 @@
+/* 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/. */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { CertUtils } from "resource://gre/modules/CertUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// This will inherit settings from the "addons" logger.
+var logger = Log.repository.getLogger("addons.productaddons");
+// We want to set the level of this logger independent from its parent to help
+// debug things like GMP updates. Link this to its own level pref.
+logger.manageLevelFromPref("extensions.logging.productaddons.level");
+
+/**
+ * Number of milliseconds after which we need to cancel `downloadXMLWithRequest`
+ * and `conservativeFetch`.
+ *
+ * Bug 1087674 suggests that the XHR/ServiceRequest we use in
+ * `downloadXMLWithRequest` 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;
+
+/**
+ * Gets the status of an XMLHttpRequest either directly or from its underlying
+ * channel.
+ *
+ * @param request
+ * The XMLHttpRequest.
+ * @returns {Object} result - An object containing the results.
+ * @returns {integer} result.status - Request status code, if available, else the channel nsresult.
+ * @returns {integer} result.channelStatus - Channel nsresult.
+ * @returns {integer} result.errorCode - Request error code.
+ */
+function getRequestStatus(request) {
+ let status = null;
+ let errorCode = null;
+ let channelStatus = null;
+
+ try {
+ status = request.status;
+ } catch (e) {}
+ try {
+ errorCode = request.errorCode;
+ } catch (e) {}
+ try {
+ channelStatus = request.channel.QueryInterface(Ci.nsIRequest).status;
+ } catch (e) {}
+
+ if (status == null) {
+ status = channelStatus;
+ }
+
+ return { status, channelStatus, errorCode };
+}
+
+/**
+ * A wrapper around `ServiceRequest` that behaves like a limited `fetch()`.
+ * This doesn't handle headers like fetch, but can be expanded as callers need.
+ *
+ * Use this in order to leverage the `beConservative` flag, for
+ * example to avoid using HTTP3 to fetch critical data.
+ *
+ * @param input a resource
+ * @returns a Response object
+ */
+async function conservativeFetch(input) {
+ return new Promise(function (resolve, reject) {
+ const request = new lazy.ServiceRequest({ mozAnon: true });
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.onerror = () => {
+ let err = new TypeError("NetworkError: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ reject(err);
+ };
+ request.ontimeout = () => {
+ let err = new TypeError("Timeout: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ reject(err);
+ };
+ request.onabort = () => {
+ let err = new DOMException("Aborted", "AbortError");
+ err.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ reject(err);
+ };
+ request.onload = () => {
+ const responseAttributes = {
+ status: request.status,
+ statusText: request.statusText,
+ url: request.responseURL,
+ };
+ resolve(new Response(request.response, responseAttributes));
+ };
+
+ const method = "GET";
+
+ request.open(method, input, true);
+
+ request.send();
+ });
+}
+
+/**
+ * Verifies the content signature on GMP's update.xml. When we fetch update.xml
+ * balrog should send back content signature headers, which this function
+ * is used to verify.
+ *
+ * @param data
+ * The data received from balrog. I.e. the xml contents of update.xml.
+ * @param contentSignatureHeader
+ * The contents of the 'content-signature' header received along with
+ * `data`.
+ * @return A promise that will resolve to nothing if the signature verification
+ * succeeds, or rejects on failure, with an Error that sets its
+ * addonCheckerErr property disambiguate failure cases and a message
+ * explaining the error.
+ */
+async function verifyGmpContentSignature(data, contentSignatureHeader) {
+ if (!contentSignatureHeader) {
+ logger.warn(
+ "Unexpected missing content signature header during content signature validation"
+ );
+ let err = new Error(
+ "Content signature validation failed: missing content signature header"
+ );
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+ // Split out the header. It should contain a the following fields, separated by a semicolon
+ // - x5u - a URI to the cert chain. See also https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.5
+ // - p384ecdsa - the signature to verify. See also https://github.com/mozilla-services/autograph/blob/main/signer/contentsignaturepki/README.md
+ const headerFields = contentSignatureHeader
+ .split(";") // Split fields...
+ .map(s => s.trim()) // Remove whitespace...
+ .map(s => [
+ // Break each field into it's name and value. This more verbose version is
+ // used instead of `split()` to handle values that contain = characters. This
+ // shouldn't happen for the signature because it's base64_url (no = padding),
+ // but it's not clear if it's possible for the x5u URL (as part of a query).
+ // Guard anyway, better safe than sorry.
+ s.substring(0, s.indexOf("=")), // Get field name...
+ s.substring(s.indexOf("=") + 1), // and field value.
+ ]);
+
+ let x5u;
+ let signature;
+ for (const [fieldName, fieldValue] of headerFields) {
+ if (fieldName == "x5u") {
+ x5u = fieldValue;
+ } else if (fieldName == "p384ecdsa") {
+ // The signature needs to contain 'p384ecdsa', so stich it back together.
+ signature = `p384ecdsa=${fieldValue}`;
+ }
+ }
+
+ if (!x5u) {
+ logger.warn("Unexpected missing x5u during content signature validation");
+ let err = Error("Content signature validation failed: missing x5u");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ if (!signature) {
+ logger.warn(
+ "Unexpected missing signature during content signature validation"
+ );
+ let err = Error("Content signature validation failed: missing signature");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ // The x5u field should contain the location of the cert chain, fetch it.
+ // Use `conservativeFetch` so we get conservative behaviour and ensure (more)
+ // reliable fetching.
+ const certChain = await (await conservativeFetch(x5u)).text();
+
+ const verifier = Cc[
+ "@mozilla.org/security/contentsignatureverifier;1"
+ ].createInstance(Ci.nsIContentSignatureVerifier);
+
+ // See bug 1771992. In the future, this may need to handle staging and dev
+ // environments in addition to just production and testing.
+ let root = Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ root = Ci.nsIX509CertDB.AppXPCShellRoot;
+ }
+
+ let valid;
+ try {
+ valid = await verifier.asyncVerifyContentSignature(
+ data,
+ signature,
+ certChain,
+ "aus.content-signature.mozilla.org",
+ root
+ );
+ } catch (err) {
+ logger.warn(`Unexpected error while validating content signature: ${err}`);
+ let newErr = new Error(`Content signature validation failed: ${err}`);
+ newErr.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ throw newErr;
+ }
+
+ if (!valid) {
+ logger.warn("Unexpected invalid content signature found during validation");
+ let err = new Error("Content signature is not valid");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_INVALID_ERR;
+ throw err;
+ }
+}
+
+/**
+ * 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 ServiceRequest request on success or
+ * rejects with a JS exception in case of error.
+ */
+function downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null
+) {
+ return new Promise((resolve, reject) => {
+ let request = new lazy.ServiceRequest();
+ // This is here to let unit test code override the ServiceRequest.
+ 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;
+ 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.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ if (event.type == "error") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ } else if (event.type == "abort") {
+ ex.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ } else if (event.type == "timeout") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ }
+ 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).requestStatus;
+ ex.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ reject(ex);
+ return;
+ }
+
+ resolve(request);
+ };
+
+ 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);
+ });
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes, and/or testing the content signature.
+ *
+ * @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.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to the DOM document downloaded or rejects
+ * with a JS exception in case of error.
+ */
+async function downloadXML(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+) {
+ let request = await downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn,
+ allowedCerts
+ );
+ if (verifyContentSignature) {
+ await verifyGmpContentSignature(
+ request.response,
+ request.getResponseHeader("content-signature")
+ );
+ }
+ return request.responseXML;
+}
+
+/**
+ * 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") {
+ let err = new Error(
+ "got node name: " +
+ document.documentElement.localName +
+ ", expected: updates"
+ );
+ err.addonCheckerErr = ProductAddonChecker.XML_PARSE_ERR;
+ throw err;
+ }
+
+ // 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 ServiceRequest.
+ *
+ * @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 sr = new lazy.ServiceRequest();
+
+ sr.onload = function (response) {
+ logger.info("downloadFile File download. status=" + sr.status);
+ if (sr.status != 200 && sr.status != 206) {
+ reject(Components.Exception("File download failed", sr.status));
+ return;
+ }
+ (async function () {
+ const path = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "tmpaddon"
+ );
+ logger.info(`Downloaded file will be saved to ${path}`);
+ await IOUtils.write(path, new Uint8Array(sr.response));
+
+ return path;
+ })().then(resolve, reject);
+ };
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading via ServiceRequest, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ reject(ex);
+ };
+ sr.addEventListener("error", fail);
+ sr.addEventListener("abort", fail);
+
+ sr.responseType = "arraybuffer";
+ try {
+ sr.open("GET", url);
+ if (options.httpsOnlyNoUpgrade) {
+ sr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT;
+ }
+ // Allow deprecated HTTP request from SystemPrincipal
+ sr.channel.loadInfo.allowDeprecatedSystemRequests = true;
+ sr.send(null);
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+}
+
+/**
+ * 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 IOUtils.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 IOUtils.computeHexDigest(path, properties.hashFunction);
+ if (digest != expectedDigest) {
+ throw new Error(
+ "Hash was `" + digest + "` but expected `" + expectedDigest + "`."
+ );
+ }
+ }
+};
+
+export const ProductAddonChecker = {
+ // More specific error names to help debug and report failures.
+ NETWORK_REQUEST_ERR: "NetworkRequestError",
+ NETWORK_TIMEOUT_ERR: "NetworkTimeoutError",
+ ABORT_ERR: "AbortError", // Doesn't have network prefix to work with existing convention.
+ VERIFICATION_MISSING_DATA_ERR: "VerificationMissingDataError",
+ VERIFICATION_FAILED_ERR: "VerificationFailedError",
+ VERIFICATION_INVALID_ERR: "VerificationInvalidError",
+ XML_PARSE_ERR: "XMLParseError",
+
+ /**
+ * Downloads a list of add-ons from a URL optionally testing the SSL
+ * certificate for certain attributes, and/or testing the content signature.
+ *
+ * @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.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @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. In the case of an error, a best effort
+ * is made to set the error addonCheckerErr property to one of the
+ * more specific names used by the product addon checker.
+ */
+ getProductAddonList(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+ ) {
+ return downloadXML(
+ url,
+ allowNonBuiltIn,
+ allowedCerts,
+ verifyContentSignature
+ ).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 IOUtils.remove(path);
+ throw e;
+ }
+ },
+};
+
+// For test use only.
+export const ProductAddonCheckerTestUtils = {
+ /**
+ * Used to override ServiceRequest calls with a mock request.
+ * @param mockRequest The mocked ServiceRequest object.
+ * @param callback Method called with the overridden ServiceRequest. The override
+ * is undone after the callback returns.
+ */
+ async overrideServiceRequest(mockRequest, callback) {
+ let originalServiceRequest = lazy.ServiceRequest;
+ lazy.ServiceRequest = function () {
+ return mockRequest;
+ };
+ try {
+ return await callback();
+ } finally {
+ lazy.ServiceRequest = originalServiceRequest;
+ }
+ },
+};