зеркало из https://github.com/mozilla/gecko-dev.git
479 строки
18 KiB
JavaScript
479 строки
18 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 { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
|
|
Cu.importGlobalProperties(["fetch"]);
|
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
|
|
"resource://gre/modules/FileUtils.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Kinto",
|
|
"resource://services-common/kinto-offline-client.js");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "KintoHttpClient",
|
|
"resource://services-common/kinto-http-client.js");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "FirefoxAdapter",
|
|
"resource://services-common/kinto-storage-adapter.js");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "CanonicalJSON",
|
|
"resource://gre/modules/CanonicalJSON.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "UptakeTelemetry",
|
|
"resource://services-common/uptake-telemetry.js");
|
|
|
|
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";
|
|
|
|
// 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.
|
|
const 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 = null;
|
|
}
|
|
|
|
get identifier() {
|
|
return `${this.bucketName}/${this.collectionName}`;
|
|
}
|
|
|
|
get filename() {
|
|
// Replace slash by OS specific path separator (eg. Windows)
|
|
const identifier = OS.Path.join(...this.identifier.split("/"));
|
|
return `${identifier}.json`;
|
|
}
|
|
|
|
/**
|
|
* Open the underlying Kinto collection, using the appropriate adapter and
|
|
* options. This acts as a context manager where the connection is closed
|
|
* once the specified `callback` has finished.
|
|
*
|
|
* @param {callback} function the async function to execute with the open SQlite connection.
|
|
* @param {Object} options additional advanced options.
|
|
* @param {string} options.bucket override bucket name of client (default: this.bucketName)
|
|
* @param {string} options.collection override collection name of client (default: this.collectionName)
|
|
* @param {string} options.path override default Sqlite path (default: kinto.sqlite)
|
|
* @param {string} options.hooks hooks to execute on synchronization (see Kinto.js docs)
|
|
*/
|
|
async openCollection(callback, options = {}) {
|
|
const { bucket = this.bucketName, path = KINTO_STORAGE_PATH } = options;
|
|
if (!this._kinto) {
|
|
this._kinto = new Kinto({bucket, adapter: FirefoxAdapter});
|
|
}
|
|
let sqliteHandle;
|
|
try {
|
|
sqliteHandle = await FirefoxAdapter.openConnection({path});
|
|
const colOptions = Object.assign({adapterOptions: {sqliteHandle}}, options);
|
|
const {collection: collectionName = this.collectionName} = options;
|
|
const collection = this._kinto.collection(collectionName, colOptions);
|
|
return await callback(collection);
|
|
} finally {
|
|
if (sqliteHandle) {
|
|
await sqliteHandle.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async loadDumpFile() {
|
|
// Replace OS specific path separator by / for URI.
|
|
const { components: folderFile } = OS.Path.split(this.filename);
|
|
const fileURI = `resource://app/defaults/${folderFile.join("/")}`;
|
|
const response = await fetch(fileURI);
|
|
if (!response.ok) {
|
|
throw new Error(`Could not read from '${fileURI}'`);
|
|
}
|
|
// Will be rejected if JSON is invalid.
|
|
return response.json();
|
|
}
|
|
|
|
async validateCollectionSignature(remote, payload, collection, options = {}) {
|
|
const {ignoreLocal} = options;
|
|
|
|
// this is a content-signature field from an autograph response.
|
|
const {x5u, signature} = await fetchCollectionMetadata(remote, collection);
|
|
const certChainResponse = await fetch(x5u)
|
|
const certChain = await certChainResponse.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} = await 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);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async 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
|
|
const colOptions = {};
|
|
if (this.signerName && enforceCollectionSigning) {
|
|
colOptions.hooks = {
|
|
"incoming-changes": [(payload, collection) => {
|
|
return this.validateCollectionSignature(remote, payload, collection);
|
|
}]
|
|
}
|
|
}
|
|
|
|
let reportStatus = null;
|
|
try {
|
|
return await this.openCollection(async (collection) => {
|
|
// Synchronize remote data into a local Sqlite DB.
|
|
let collectionLastModified = await 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 = await this.loadDumpFile();
|
|
await collection.loadDump(initialData.data);
|
|
collectionLastModified = await 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);
|
|
reportStatus = UptakeTelemetry.STATUS.UP_TO_DATE;
|
|
return;
|
|
}
|
|
|
|
// Fetch changes from server.
|
|
try {
|
|
// Server changes have priority during synchronization.
|
|
const strategy = Kinto.syncStrategy.SERVER_WINS;
|
|
const {ok} = await collection.sync({remote, strategy});
|
|
if (!ok) {
|
|
// Some synchronization conflicts occured.
|
|
reportStatus = UptakeTelemetry.STATUS.CONFLICT_ERROR;
|
|
throw new Error("Sync failed");
|
|
}
|
|
} catch (e) {
|
|
if (e.message == INVALID_SIGNATURE) {
|
|
// Signature verification failed during synchronzation.
|
|
reportStatus = UptakeTelemetry.STATUS.SIGNATURE_ERROR;
|
|
// 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 = await fetchRemoteCollection(remote, collection);
|
|
try {
|
|
await this.validateCollectionSignature(remote, payload, collection, {ignoreLocal: true});
|
|
} catch (e) {
|
|
reportStatus = UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
|
|
throw e;
|
|
}
|
|
// 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 = await collection.db.getLastModified();
|
|
if (payload.last_modified >= localLastModified) {
|
|
await collection.clear();
|
|
await collection.loadDump(payload.data);
|
|
}
|
|
} else {
|
|
// The sync has thrown, it can be a network or a general error.
|
|
if (/NetworkError/.test(e.message)) {
|
|
reportStatus = UptakeTelemetry.STATUS.NETWORK_ERROR;
|
|
} else if (/Backoff/.test(e.message)) {
|
|
reportStatus = UptakeTelemetry.STATUS.BACKOFF;
|
|
} else {
|
|
reportStatus = UptakeTelemetry.STATUS.SYNC_ERROR;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
// Read local collection of records.
|
|
const {data} = await collection.list();
|
|
|
|
// Handle the obtained records (ie. apply locally).
|
|
try {
|
|
await this.processCallback(data);
|
|
} catch (e) {
|
|
reportStatus = UptakeTelemetry.STATUS.APPLY_ERROR;
|
|
throw e;
|
|
}
|
|
|
|
// Track last update.
|
|
this.updateLastCheck(serverTime);
|
|
|
|
}, colOptions);
|
|
} catch (e) {
|
|
// No specific error was tracked, mark it as unknown.
|
|
if (reportStatus === null) {
|
|
reportStatus = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
|
|
}
|
|
throw e;
|
|
} finally {
|
|
// No error was reported, this is a success!
|
|
if (reportStatus === null) {
|
|
reportStatus = UptakeTelemetry.STATUS.SUCCESS;
|
|
}
|
|
// Report success/error status to Telemetry.
|
|
UptakeTelemetry.report(this.identifier, reportStatus);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async 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.
|
|
*/
|
|
async 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.
|
|
*/
|
|
async 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);
|
|
|
|
await OS.File.makeDir(blocklistFolder, {from: OS.Constants.Path.profileDir});
|
|
|
|
const serialized = JSON.stringify({data: records}, null, 2);
|
|
try {
|
|
await 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"
|
|
);
|