summaryrefslogtreecommitdiffstats
path: root/services/common/blocklist-clients.js
diff options
context:
space:
mode:
Diffstat (limited to 'services/common/blocklist-clients.js')
-rw-r--r--services/common/blocklist-clients.js310
1 files changed, 310 insertions, 0 deletions
diff --git a/services/common/blocklist-clients.js b/services/common/blocklist-clients.js
new file mode 100644
index 000000000..fc51aaca4
--- /dev/null
+++ b/services/common/blocklist-clients.js
@@ -0,0 +1,310 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["AddonBlocklistClient",
+ "GfxBlocklistClient",
+ "OneCRLBlocklistClient",
+ "PluginBlocklistClient",
+ "FILENAME_ADDONS_JSON",
+ "FILENAME_GFX_JSON",
+ "FILENAME_PLUGINS_JSON"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+const { Task } = Cu.import("resource://gre/modules/Task.jsm");
+const { OS } = Cu.import("resource://gre/modules/osfile.jsm");
+Cu.importGlobalProperties(["fetch"]);
+
+const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+const { KintoHttpClient } = Cu.import("resource://services-common/kinto-http-client.js");
+const { CanonicalJSON } = Components.utils.import("resource://gre/modules/CanonicalJSON.jsm");
+
+const PREF_SETTINGS_SERVER = "services.settings.server";
+const PREF_BLOCKLIST_BUCKET = "services.blocklist.bucket";
+const PREF_BLOCKLIST_ONECRL_COLLECTION = "services.blocklist.onecrl.collection";
+const PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS = "services.blocklist.onecrl.checked";
+const PREF_BLOCKLIST_ADDONS_COLLECTION = "services.blocklist.addons.collection";
+const PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS = "services.blocklist.addons.checked";
+const PREF_BLOCKLIST_PLUGINS_COLLECTION = "services.blocklist.plugins.collection";
+const PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS = "services.blocklist.plugins.checked";
+const PREF_BLOCKLIST_GFX_COLLECTION = "services.blocklist.gfx.collection";
+const PREF_BLOCKLIST_GFX_CHECKED_SECONDS = "services.blocklist.gfx.checked";
+const PREF_BLOCKLIST_ENFORCE_SIGNING = "services.blocklist.signing.enforced";
+
+const INVALID_SIGNATURE = "Invalid content/signature";
+
+this.FILENAME_ADDONS_JSON = "blocklist-addons.json";
+this.FILENAME_GFX_JSON = "blocklist-gfx.json";
+this.FILENAME_PLUGINS_JSON = "blocklist-plugins.json";
+
+function mergeChanges(localRecords, changes) {
+ // Kinto.js adds attributes to local records that aren't present on server.
+ // (e.g. _status)
+ const stripPrivateProps = (obj) => {
+ return Object.keys(obj).reduce((current, key) => {
+ if (!key.startsWith("_")) {
+ current[key] = obj[key];
+ }
+ return current;
+ }, {});
+ };
+
+ const records = {};
+ // Local records by id.
+ localRecords.forEach((record) => records[record.id] = stripPrivateProps(record));
+ // All existing records are replaced by the version from the server.
+ changes.forEach((record) => records[record.id] = record);
+
+ return Object.values(records)
+ // Filter out deleted records.
+ .filter((record) => record.deleted != true)
+ // Sort list by record id.
+ .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
+}
+
+
+function fetchCollectionMetadata(collection) {
+ const client = new KintoHttpClient(collection.api.remote);
+ return client.bucket(collection.bucket).collection(collection.name).getData()
+ .then(result => {
+ return result.signature;
+ });
+}
+
+function fetchRemoteCollection(collection) {
+ const client = new KintoHttpClient(collection.api.remote);
+ return client.bucket(collection.bucket)
+ .collection(collection.name)
+ .listRecords({sort: "id"});
+}
+
+/**
+ * Helper to instantiate a Kinto client based on preferences for remote server
+ * URL and bucket name. It uses the `FirefoxAdapter` which relies on SQLite to
+ * persist the local DB.
+ */
+function kintoClient() {
+ let base = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
+ let bucket = Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET);
+
+ let Kinto = loadKinto();
+
+ let FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
+
+ let config = {
+ remote: base,
+ bucket: bucket,
+ adapter: FirefoxAdapter,
+ };
+
+ return new Kinto(config);
+}
+
+
+class BlocklistClient {
+
+ constructor(collectionName, lastCheckTimePref, processCallback, signerName) {
+ this.collectionName = collectionName;
+ this.lastCheckTimePref = lastCheckTimePref;
+ this.processCallback = processCallback;
+ this.signerName = signerName;
+ }
+
+ validateCollectionSignature(payload, collection, ignoreLocal) {
+ return Task.spawn((function* () {
+ // this is a content-signature field from an autograph response.
+ const {x5u, signature} = yield fetchCollectionMetadata(collection);
+ const certChain = yield fetch(x5u).then((res) => res.text());
+
+ const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+ .createInstance(Ci.nsIContentSignatureVerifier);
+
+ let toSerialize;
+ if (ignoreLocal) {
+ toSerialize = {
+ last_modified: `${payload.last_modified}`,
+ data: payload.data
+ };
+ } else {
+ const localRecords = (yield collection.list()).data;
+ const records = mergeChanges(localRecords, payload.changes);
+ toSerialize = {
+ last_modified: `${payload.lastModified}`,
+ data: records
+ };
+ }
+
+ const serialized = CanonicalJSON.stringify(toSerialize);
+
+ if (verifier.verifyContentSignature(serialized, "p384ecdsa=" + signature,
+ certChain,
+ this.signerName)) {
+ // In case the hash is valid, apply the changes locally.
+ return payload;
+ }
+ throw new Error(INVALID_SIGNATURE);
+ }).bind(this));
+ }
+
+ /**
+ * Synchronize from Kinto server, if necessary.
+ *
+ * @param {int} lastModified the lastModified date (on the server) for
+ the remote collection.
+ * @param {Date} serverTime the current date return by the server.
+ * @return {Promise} which rejects on sync or process failure.
+ */
+ maybeSync(lastModified, serverTime) {
+ let db = kintoClient();
+ let opts = {};
+ let enforceCollectionSigning =
+ Services.prefs.getBoolPref(PREF_BLOCKLIST_ENFORCE_SIGNING);
+
+ // if there is a signerName and collection signing is enforced, add a
+ // hook for incoming changes that validates the signature
+ if (this.signerName && enforceCollectionSigning) {
+ opts.hooks = {
+ "incoming-changes": [this.validateCollectionSignature.bind(this)]
+ }
+ }
+
+ let collection = db.collection(this.collectionName, opts);
+
+ return Task.spawn((function* syncCollection() {
+ try {
+ yield collection.db.open();
+
+ let collectionLastModified = yield collection.db.getLastModified();
+ // If the data is up to date, there's no need to sync. We still need
+ // to record the fact that a check happened.
+ if (lastModified <= collectionLastModified) {
+ this.updateLastCheck(serverTime);
+ return;
+ }
+ // Fetch changes from server.
+ try {
+ let syncResult = yield collection.sync();
+ if (!syncResult.ok) {
+ throw new Error("Sync failed");
+ }
+ } catch (e) {
+ if (e.message == INVALID_SIGNATURE) {
+ // if sync fails with a signature error, it's likely that our
+ // local data has been modified in some way.
+ // We will attempt to fix this by retrieving the whole
+ // remote collection.
+ let payload = yield fetchRemoteCollection(collection);
+ yield this.validateCollectionSignature(payload, collection, true);
+ // if the signature is good (we haven't thrown), and the remote
+ // last_modified is newer than the local last_modified, replace the
+ // local data
+ const localLastModified = yield collection.db.getLastModified();
+ if (payload.last_modified >= localLastModified) {
+ yield collection.clear();
+ yield collection.loadDump(payload.data);
+ }
+ } else {
+ throw e;
+ }
+ }
+ // Read local collection of records.
+ let list = yield collection.list();
+
+ yield this.processCallback(list.data);
+
+ // Track last update.
+ this.updateLastCheck(serverTime);
+ } finally {
+ collection.db.close();
+ }
+ }).bind(this));
+ }
+
+ /**
+ * Save last time server was checked in users prefs.
+ *
+ * @param {Date} serverTime the current date return by server.
+ */
+ updateLastCheck(serverTime) {
+ let checkedServerTimeInSeconds = Math.round(serverTime / 1000);
+ Services.prefs.setIntPref(this.lastCheckTimePref, checkedServerTimeInSeconds);
+ }
+}
+
+/**
+ * Revoke the appropriate certificates based on the records from the blocklist.
+ *
+ * @param {Object} records current records in the local db.
+ */
+function* updateCertBlocklist(records) {
+ let certList = Cc["@mozilla.org/security/certblocklist;1"]
+ .getService(Ci.nsICertBlocklist);
+ for (let item of records) {
+ try {
+ if (item.issuerName && item.serialNumber) {
+ certList.revokeCertByIssuerAndSerial(item.issuerName,
+ item.serialNumber);
+ } else if (item.subject && item.pubKeyHash) {
+ certList.revokeCertBySubjectAndPubKey(item.subject,
+ item.pubKeyHash);
+ }
+ } catch (e) {
+ // prevent errors relating to individual blocklist entries from
+ // causing sync to fail. At some point in the future, we may want to
+ // accumulate telemetry on these failures.
+ Cu.reportError(e);
+ }
+ }
+ certList.saveEntries();
+}
+
+/**
+ * Write list of records into JSON file, and notify nsBlocklistService.
+ *
+ * @param {String} filename path relative to profile dir.
+ * @param {Object} records current records in the local db.
+ */
+function* updateJSONBlocklist(filename, records) {
+ // Write JSON dump for synchronous load at startup.
+ const path = OS.Path.join(OS.Constants.Path.profileDir, filename);
+ const serialized = JSON.stringify({data: records}, null, 2);
+ try {
+ yield OS.File.writeAtomic(path, serialized, {tmpPath: path + ".tmp"});
+
+ // Notify change to `nsBlocklistService`
+ const eventData = {filename: filename};
+ Services.cpmm.sendAsyncMessage("Blocklist:reload-from-disk", eventData);
+ } catch(e) {
+ Cu.reportError(e);
+ }
+}
+
+
+this.OneCRLBlocklistClient = new BlocklistClient(
+ Services.prefs.getCharPref(PREF_BLOCKLIST_ONECRL_COLLECTION),
+ PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS,
+ updateCertBlocklist,
+ "onecrl.content-signature.mozilla.org"
+);
+
+this.AddonBlocklistClient = new BlocklistClient(
+ Services.prefs.getCharPref(PREF_BLOCKLIST_ADDONS_COLLECTION),
+ PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS,
+ updateJSONBlocklist.bind(undefined, FILENAME_ADDONS_JSON)
+);
+
+this.GfxBlocklistClient = new BlocklistClient(
+ Services.prefs.getCharPref(PREF_BLOCKLIST_GFX_COLLECTION),
+ PREF_BLOCKLIST_GFX_CHECKED_SECONDS,
+ updateJSONBlocklist.bind(undefined, FILENAME_GFX_JSON)
+);
+
+this.PluginBlocklistClient = new BlocklistClient(
+ Services.prefs.getCharPref(PREF_BLOCKLIST_PLUGINS_COLLECTION),
+ PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS,
+ updateJSONBlocklist.bind(undefined, FILENAME_PLUGINS_JSON)
+);