gecko-dev/services/common/blocklist-clients.js

410 lines
15 KiB
JavaScript

/* 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",
"PinningBlocklistClient",
"PluginBlocklistClient"];
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
Cu.importGlobalProperties(["fetch"]);
const { Kinto } = Cu.import("resource://services-common/kinto-offline-client.js", {});
const { KintoHttpClient } = Cu.import("resource://services-common/kinto-http-client.js", {});
const { FirefoxAdapter } = Cu.import("resource://services-common/kinto-storage-adapter.js", {});
const { CanonicalJSON } = Components.utils.import("resource://gre/modules/CanonicalJSON.jsm", {});
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
const KEY_APPDIR = "XCurProcD";
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_PINNING_ENABLED = "services.blocklist.pinning.enabled";
const PREF_BLOCKLIST_PINNING_BUCKET = "services.blocklist.pinning.bucket";
const PREF_BLOCKLIST_PINNING_COLLECTION = "services.blocklist.pinning.collection";
const PREF_BLOCKLIST_PINNING_CHECKED_SECONDS = "services.blocklist.pinning.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";
// FIXME: this was the default path in earlier versions of
// FirefoxAdapter, so for backwards compatibility we maintain this
// filename, even though it isn't descriptive of who is using it.
this.KINTO_STORAGE_PATH = "kinto.sqlite";
function mergeChanges(collection, localRecords, changes) {
const records = {};
// Local records by id.
localRecords.forEach((record) => records[record.id] = collection.cleanLocalFields(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) => {
if (a.id < b.id) {
return -1;
}
return a.id > b.id ? 1 : 0;
});
}
function fetchCollectionMetadata(remote, collection) {
const client = new KintoHttpClient(remote);
return client.bucket(collection.bucket).collection(collection.name).getData()
.then(result => {
return result.signature;
});
}
function fetchRemoteCollection(remote, collection) {
const client = new KintoHttpClient(remote);
return client.bucket(collection.bucket)
.collection(collection.name)
.listRecords({sort: "id"});
}
class BlocklistClient {
constructor(collectionName, lastCheckTimePref, processCallback, bucketName, signerName) {
this.collectionName = collectionName;
this.lastCheckTimePref = lastCheckTimePref;
this.processCallback = processCallback;
this.bucketName = bucketName;
this.signerName = signerName;
this._kinto = new Kinto({
bucket: bucketName,
adapter: FirefoxAdapter,
});
}
get filename() {
return `${this.bucketName}/${this.collectionName}.json`;
}
/**
* Load the the JSON file distributed with the release for this blocklist.
*
* For Bug 1257565 this method will have to try to load the file from the profile,
* in order to leverage the updateJSONBlocklist() below, which writes a new
* dump each time the collection changes.
*/
loadDumpFile() {
const fileURI = `resource://app/defaults/${this.filename}`;
return Task.spawn(function* loadFile() {
const response = yield fetch(fileURI);
if (!response.ok) {
throw new Error(`Could not read from '${fileURI}'`);
}
// Will be rejected if JSON is invalid.
return yield response.json();
});
}
validateCollectionSignature(remote, payload, collection, options = {}) {
const {ignoreLocal} = options;
return Task.spawn((function* () {
// this is a content-signature field from an autograph response.
const {x5u, signature} = yield fetchCollectionMetadata(remote, 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 {data: localRecords} = yield collection.list();
const records = mergeChanges(collection, 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.
* @param {Object} options additional advanced options.
* @param {bool} options.loadDump load initial dump from disk on first sync (default: true)
* @return {Promise} which rejects on sync or process failure.
*/
maybeSync(lastModified, serverTime, options = {loadDump: true}) {
const {loadDump} = options;
const remote = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
const 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
let hooks;
if (this.signerName && enforceCollectionSigning) {
hooks = {
"incoming-changes": [(payload, collection) => {
return this.validateCollectionSignature(remote, payload, collection);
}]
}
}
return Task.spawn((function* syncCollection() {
let sqliteHandle;
try {
// Synchronize remote data into a local Sqlite DB.
sqliteHandle = yield FirefoxAdapter.openConnection({path: KINTO_STORAGE_PATH});
const options = {
hooks,
adapterOptions: {sqliteHandle},
};
const collection = this._kinto.collection(this.collectionName, options);
let collectionLastModified = yield collection.db.getLastModified();
// If there is no data currently in the collection, attempt to import
// initial data from the application defaults.
// This allows to avoid synchronizing the whole collection content on
// cold start.
if (!collectionLastModified && loadDump) {
try {
const initialData = yield this.loadDumpFile();
yield collection.db.loadDump(initialData.data);
collectionLastModified = yield collection.db.getLastModified();
} catch (e) {
// Report but go-on.
Cu.reportError(e);
}
}
// 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 {
const {ok} = yield collection.sync({remote});
if (!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.
const payload = yield fetchRemoteCollection(remote, collection);
yield this.validateCollectionSignature(remote, payload, collection, {ignoreLocal: 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.
const {data} = yield collection.list();
yield this.processCallback(data);
// Track last update.
this.updateLastCheck(serverTime);
} finally {
yield sqliteHandle.close();
}
}).bind(this));
}
/**
* Save last time server was checked in users prefs.
*
* @param {Date} serverTime the current date return by server.
*/
updateLastCheck(serverTime) {
const 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) {
const 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. We will accumulate telemetry on these failures in
// bug 1254099.
Cu.reportError(e);
}
}
certList.saveEntries();
}
/**
* Modify the appropriate security pins based on records from the remote
* collection.
*
* @param {Object} records current records in the local db.
*/
function* updatePinningList(records) {
if (!Services.prefs.getBoolPref(PREF_BLOCKLIST_PINNING_ENABLED)) {
return;
}
const appInfo = Cc["@mozilla.org/xre/app-info;1"]
.getService(Ci.nsIXULAppInfo);
const siteSecurityService = Cc["@mozilla.org/ssservice;1"]
.getService(Ci.nsISiteSecurityService);
// clear the current preload list
siteSecurityService.clearPreloads();
// write each KeyPin entry to the preload list
for (let item of records) {
try {
const {pinType, pins = [], versions} = item;
if (versions.indexOf(appInfo.version) != -1) {
if (pinType == "KeyPin" && pins.length) {
siteSecurityService.setKeyPins(item.hostName,
item.includeSubdomains,
item.expires,
pins.length,
pins, true);
}
if (pinType == "STSPin") {
siteSecurityService.setHSTSPreload(item.hostName,
item.includeSubdomains,
item.expires);
}
}
} catch (e) {
// prevent errors relating to individual preload entries from causing
// sync to fail. We will accumulate telemetry for such failures in bug
// 1254099.
}
}
}
/**
* 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 blocklistFolder = OS.Path.dirname(path);
yield OS.File.makeDir(blocklistFolder, {from: OS.Constants.Path.profileDir});
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};
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,
Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
"onecrl.content-signature.mozilla.org"
);
this.AddonBlocklistClient = new BlocklistClient(
Services.prefs.getCharPref(PREF_BLOCKLIST_ADDONS_COLLECTION),
PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS,
(records) => updateJSONBlocklist(this.AddonBlocklistClient.filename, records),
Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
);
this.GfxBlocklistClient = new BlocklistClient(
Services.prefs.getCharPref(PREF_BLOCKLIST_GFX_COLLECTION),
PREF_BLOCKLIST_GFX_CHECKED_SECONDS,
(records) => updateJSONBlocklist(this.GfxBlocklistClient.filename, records),
Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
);
this.PluginBlocklistClient = new BlocklistClient(
Services.prefs.getCharPref(PREF_BLOCKLIST_PLUGINS_COLLECTION),
PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS,
(records) => updateJSONBlocklist(this.PluginBlocklistClient.filename, records),
Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
);
this.PinningPreloadClient = new BlocklistClient(
Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_COLLECTION),
PREF_BLOCKLIST_PINNING_CHECKED_SECONDS,
updatePinningList,
Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_BUCKET),
"pinning-preload.content-signature.mozilla.org"
);