summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs643
1 files changed, 643 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
new file mode 100644
index 0000000000..a3935a26f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
@@ -0,0 +1,643 @@
+/* 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/. */
+
+/**
+ * The AddonUpdateChecker is responsible for retrieving the update information
+ * from an add-on's remote update manifest.
+ */
+
+const TIMEOUT = 60 * 1000;
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.update-checker";
+
+// Create a new logger for use by the Addons Update Checker
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Sanitizes the update URL in an update item, as returned by
+ * parseRDFManifest and parseJSONManifest. Ensures that:
+ *
+ * - The URL is secure, or secured by a strong enough hash.
+ * - The security principal of the update manifest has permission to
+ * load the URL.
+ *
+ * @param aUpdate
+ * The update item to sanitize.
+ * @param aRequest
+ * The XMLHttpRequest used to load the manifest.
+ * @param aHashPattern
+ * The regular expression used to validate the update hash.
+ * @param aHashString
+ * The human-readable string specifying which hash functions
+ * are accepted.
+ */
+function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) {
+ if (aUpdate.updateURL) {
+ let scriptSecurity = Services.scriptSecurityManager;
+ let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel);
+ try {
+ // This logs an error on failure, so no need to log it a second time
+ scriptSecurity.checkLoadURIStrWithPrincipal(
+ principal,
+ aUpdate.updateURL,
+ scriptSecurity.DISALLOW_SCRIPT
+ );
+ } catch (e) {
+ delete aUpdate.updateURL;
+ return;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aUpdate.updateURL.startsWith("https:") &&
+ !aHashPattern.test(aUpdate.updateHash)
+ ) {
+ logger.warn(
+ `Update link ${aUpdate.updateURL} is not secure and is not verified ` +
+ `by a strong enough hash (needs to be ${aHashString}).`
+ );
+ delete aUpdate.updateURL;
+ delete aUpdate.updateHash;
+ }
+ }
+}
+
+/**
+ * Parses an JSON update manifest into an array of update objects.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aRequest
+ * The XMLHttpRequest that has retrieved the update manifest
+ * @param aManifestData
+ * The pre-parsed manifest, as a JSON object tree
+ * @return an array of update objects
+ * @throws if the update manifest is invalid in any way
+ */
+function parseJSONManifest(aId, aRequest, aManifestData) {
+ let TYPE_CHECK = {
+ array: val => Array.isArray(val),
+ object: val => val && typeof val == "object" && !Array.isArray(val),
+ };
+
+ function getProperty(aObj, aProperty, aType, aDefault = undefined) {
+ if (!(aProperty in aObj)) {
+ return aDefault;
+ }
+
+ let value = aObj[aProperty];
+
+ let matchesType =
+ aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType;
+ if (!matchesType) {
+ throw Components.Exception(
+ `Update manifest property '${aProperty}' has incorrect type (expected ${aType})`
+ );
+ }
+
+ return value;
+ }
+
+ function getRequiredProperty(aObj, aProperty, aType) {
+ let value = getProperty(aObj, aProperty, aType);
+ if (value === undefined) {
+ throw Components.Exception(
+ `Update manifest is missing a required ${aProperty} property.`
+ );
+ }
+ return value;
+ }
+
+ let manifest = aManifestData;
+
+ if (!TYPE_CHECK.object(manifest)) {
+ throw Components.Exception(
+ "Root element of update manifest must be a JSON object literal"
+ );
+ }
+
+ // The set of add-ons this manifest has updates for
+ let addons = getProperty(manifest, "addons", "object");
+
+ if (!addons) {
+ let keys = Object.keys(manifest);
+ if (keys.length) {
+ // "addons" property is optional. The presence of other properties may be
+ // a sign of a mistake, so print a warning to help with debugging.
+ logger.warn(
+ `Update manifest for ${aId} is missing the "addons" property, found ${keys} instead.`
+ );
+ } else {
+ // If the add-on isn't listed, the update server may return an empty
+ // response.
+ logger.warn(`Received empty update manifest for ${aId}.`);
+ }
+ return [];
+ }
+
+ // The entry for this particular add-on
+ let addon = getProperty(addons, aId, "object");
+
+ // A missing entry doesn't count as a failure, just as no avialable update
+ // information
+ if (!addon) {
+ logger.warn("Update manifest did not contain an entry for " + aId);
+ return [];
+ }
+
+ // The list of available updates
+ let updates = getProperty(addon, "updates", "array", []);
+
+ let results = [];
+
+ for (let update of updates) {
+ let version = getRequiredProperty(update, "version", "string");
+
+ logger.debug(`Found an update entry for ${aId} version ${version}`);
+
+ let applications = getProperty(update, "applications", "object", {
+ gecko: {},
+ });
+
+ // "gecko" is currently the only supported application entry. If
+ // it's missing, skip this update.
+ if (!("gecko" in applications)) {
+ logger.debug(
+ "gecko not in application entry, skipping update of ${addon}"
+ );
+ continue;
+ }
+
+ let app = getProperty(applications, "gecko", "object");
+
+ let appEntry = {
+ id: TOOLKIT_ID,
+ minVersion: getProperty(
+ app,
+ "strict_min_version",
+ "string",
+ lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion
+ ),
+ maxVersion: "*",
+ };
+
+ let result = {
+ id: aId,
+ version,
+ updateURL: getProperty(update, "update_link", "string"),
+ updateHash: getProperty(update, "update_hash", "string"),
+ updateInfoURL: getProperty(update, "update_info_url", "string"),
+ strictCompatibility: false,
+ targetApplications: [appEntry],
+ };
+
+ if ("strict_max_version" in app) {
+ if ("advisory_max_version" in app) {
+ logger.warn(
+ "Ignoring 'advisory_max_version' update manifest property for " +
+ aId +
+ " property since 'strict_max_version' also present"
+ );
+ }
+
+ appEntry.maxVersion = getProperty(app, "strict_max_version", "string");
+ result.strictCompatibility = appEntry.maxVersion != "*";
+ } else if ("advisory_max_version" in app) {
+ appEntry.maxVersion = getProperty(app, "advisory_max_version", "string");
+ }
+
+ // Add an app entry for the current API ID, too, so that it overrides any
+ // existing app-specific entries, which would take priority over the toolkit
+ // entry.
+ //
+ // Note: This currently only has any effect on legacy extensions (mainly
+ // those used in tests), since WebExtensions cannot yet specify app-specific
+ // compatibility ranges.
+ result.targetApplications.push(
+ Object.assign({}, appEntry, { id: Services.appinfo.ID })
+ );
+
+ // The JSON update protocol requires an SHA-2 hash. RDF still
+ // supports SHA-1, for compatibility reasons.
+ sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512");
+
+ results.push(result);
+ }
+ return results;
+}
+
+/**
+ * Starts downloading an update manifest and then passes it to an appropriate
+ * parser to convert to an array of update objects
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the update manifest
+ * @param aObserver
+ * An observer to pass results to
+ */
+function UpdateParser(aId, aUrl, aObserver) {
+ this.id = aId;
+ this.observer = aObserver;
+ this.url = aUrl;
+
+ logger.debug("Requesting " + aUrl);
+ try {
+ this.request = new lazy.ServiceRequest({ mozAnon: true });
+ this.request.open("GET", this.url, true);
+ this.request.channel.notificationCallbacks =
+ new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to cache.
+ this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ this.request.overrideMimeType("text/plain");
+ this.request.timeout = TIMEOUT;
+ this.request.addEventListener("load", () => this.onLoad());
+ this.request.addEventListener("error", () => this.onError());
+ this.request.addEventListener("timeout", () => this.onTimeout());
+ this.request.send(null);
+ } catch (e) {
+ logger.error(`Failed to request update manifest for ${this.id}`, e);
+ }
+}
+
+UpdateParser.prototype = {
+ id: null,
+ observer: null,
+ request: null,
+ url: null,
+
+ /**
+ * Called when the manifest has been successfully loaded.
+ */
+ onLoad() {
+ let request = this.request;
+ this.request = null;
+ this._doneAt = new Error("place holder");
+
+ try {
+ lazy.CertUtils.checkCert(
+ request.channel,
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ logger.warn("Request failed: " + this.url + " - " + e);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ if (!Components.isSuccessCode(request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + request.status);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let channel = request.channel;
+ if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ channel.responseStatus +
+ ": " +
+ channel.responseStatusText
+ );
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let results;
+ try {
+ let json = JSON.parse(request.responseText);
+ results = parseJSONManifest(this.id, request, json);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete failed to parse update manifest for ${this.id}`,
+ e
+ );
+ this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR);
+ return;
+ }
+
+ if ("onUpdateCheckComplete" in this.observer) {
+ try {
+ this.observer.onUpdateCheckComplete(results);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete notification failed for ${this.id}`,
+ e
+ );
+ }
+ } else {
+ logger.warn(
+ "onUpdateCheckComplete may not properly cancel",
+ new Error("stack marker")
+ );
+ }
+ },
+
+ /**
+ * Called when the request times out
+ */
+ onTimeout() {
+ this.request = null;
+ this._doneAt = new Error("Timed out");
+ logger.warn("Request for " + this.url + " timed out");
+ this.notifyError(lazy.AddonManager.ERROR_TIMEOUT);
+ },
+
+ /**
+ * Called when the manifest failed to load.
+ */
+ onError() {
+ if (!Components.isSuccessCode(this.request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + this.request.status);
+ } else if (this.request.channel instanceof Ci.nsIHttpChannel) {
+ try {
+ if (this.request.channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ this.request.channel.responseStatus +
+ ": " +
+ this.request.channel.responseStatusText
+ );
+ }
+ } catch (e) {
+ logger.warn("HTTP Request failed for an unknown reason");
+ }
+ } else {
+ logger.warn("Request failed for an unknown reason");
+ }
+
+ this.request = null;
+ this._doneAt = new Error("UP_onError");
+
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ },
+
+ /**
+ * Helper method to notify the observer that an error occurred.
+ */
+ notifyError(aStatus) {
+ if ("onUpdateCheckError" in this.observer) {
+ try {
+ this.observer.onUpdateCheckError(aStatus);
+ } catch (e) {
+ logger.warn("onUpdateCheckError notification failed", e);
+ }
+ }
+ },
+
+ /**
+ * Called to cancel an in-progress update check.
+ */
+ cancel() {
+ if (!this.request) {
+ logger.error("Trying to cancel already-complete request", this._doneAt);
+ return;
+ }
+ this.request.abort();
+ this.request = null;
+ this._doneAt = new Error("UP_cancel");
+ this.notifyError(lazy.AddonManager.ERROR_CANCELLED);
+ },
+};
+
+/**
+ * Tests if an update matches a version of the application or platform
+ *
+ * @param aUpdate
+ * The available update
+ * @param aAppVersion
+ * The application version to use
+ * @param aPlatformVersion
+ * The platform version to use
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return true if the update is compatible with the application/platform
+ */
+function matchesVersions(
+ aUpdate,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+) {
+ if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) {
+ aIgnoreMaxVersion = false;
+ }
+
+ let result = false;
+ for (let app of aUpdate.targetApplications) {
+ if (app.id == Services.appinfo.ID) {
+ return (
+ Services.vc.compare(aAppVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aAppVersion, app.maxVersion) <= 0)
+ );
+ }
+ if (app.id == TOOLKIT_ID) {
+ result =
+ Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0);
+ }
+ }
+ return result;
+}
+
+export var AddonUpdateChecker = {
+ /**
+ * Retrieves the best matching compatibility update for the application from
+ * a list of available update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aVersion
+ * The version of the add-on to get new compatibility information for
+ * @param aIgnoreCompatibility
+ * An optional parameter to get the first compatibility update that
+ * is compatible with any version of the application or toolkit
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return an update object if one matches or null if not
+ */
+ getCompatibilityUpdate(
+ aUpdates,
+ aVersion,
+ aIgnoreCompatibility,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ for (let update of aUpdates) {
+ if (Services.vc.compare(update.version, aVersion) == 0) {
+ if (aIgnoreCompatibility) {
+ for (let targetApp of update.targetApplications) {
+ let id = targetApp.id;
+ if (id == Services.appinfo.ID || id == TOOLKIT_ID) {
+ return update;
+ }
+ }
+ } else if (
+ matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ return update;
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Asynchronously returns the newest available update from a list of update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aAddon
+ * The add-on that is being updated.
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * When determining compatible updates, ignore maxVersion. Optional.
+ * @param aIgnoreStrictCompat
+ * When determining compatible updates, ignore strictCompatibility. Optional.
+ * @return an update object if one matches or null if not
+ */
+ async getNewestCompatibleUpdate(
+ aUpdates,
+ aAddon,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let newestVersion = aAddon.version;
+ let newest = null;
+ let blocked = null;
+ let blockedState;
+ for (let update of aUpdates) {
+ if (!update.updateURL) {
+ continue;
+ }
+ if (Services.vc.compare(newestVersion, update.version) >= 0) {
+ // Update older than add-on version or older than previous result.
+ continue;
+ }
+ if (
+ !matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ continue;
+ }
+ let state = await lazy.Blocklist.getAddonBlocklistState(
+ update,
+ aAppVersion,
+ aPlatformVersion
+ );
+ if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ if (
+ !blocked ||
+ Services.vc.compare(blocked.version, update.version) < 0
+ ) {
+ blocked = update;
+ blockedState = state;
+ }
+ continue;
+ }
+ newest = update;
+ newestVersion = update.version;
+ }
+ if (
+ blocked &&
+ (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0)
+ ) {
+ // If |newest| has a higher version than |blocked|, then the add-on would
+ // not be considered for installation. But if |blocked| would otherwise
+ // be eligible for installation, then report to telemetry that installation
+ // has been blocked because of the blocklist.
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ {
+ id: aAddon.id,
+ version: blocked.version,
+ blocklistState: blockedState,
+ },
+ "addon_update_check"
+ );
+ }
+ return newest;
+ },
+
+ /**
+ * Starts an update check.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the add-on's update manifest
+ * @param aObserver
+ * An observer to notify of results
+ * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut
+ * down in-progress update requests
+ */
+ checkForUpdates(aId, aUrl, aObserver) {
+ return new UpdateParser(aId, aUrl, aObserver);
+ },
+};