diff options
Diffstat (limited to 'services/common/blocklist-clients.js')
-rw-r--r-- | services/common/blocklist-clients.js | 310 |
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) +); |