diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs | 601 |
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; + } + }, +}; |