Bug 1257565 - Load addons/plugins/gfx blocklist from JSON files r=mossop draft
authorMathieu Leplatre <mathieu@mozilla.com>
Fri, 16 Jun 2017 11:53:13 +0200
changeset 655875 a6e455380cdbffe70f270203921765eae3c16f33
parent 655874 c4c5b6d928adb599a1dbf74b50c73e130d8006ff
child 655876 ec694449eb74d5b785647ef23af52df7ac4ceb86
push id76975
push usermleplatre@mozilla.com
push dateWed, 30 Aug 2017 12:43:51 +0000
reviewersmossop
bugs1257565
milestone57.0a1
Bug 1257565 - Load addons/plugins/gfx blocklist from JSON files r=mossop MozReview-Commit-ID: B5qn3KHMW6R
services/common/blocklist-clients.js
toolkit/mozapps/extensions/nsBlocklistService.js
toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_json/sample.json
toolkit/mozapps/extensions/test/xpcshell/test_blocklist_json.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -50,17 +50,16 @@ const PREF_BLOCKLIST_ENFORCE_SIGNING    
 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)
--- a/toolkit/mozapps/extensions/nsBlocklistService.js
+++ b/toolkit/mozapps/extensions/nsBlocklistService.js
@@ -9,16 +9,18 @@
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://gre/modules/AppConstants.jsm");
 
+const BlocklistClients = Components.utils.import("resource://services-common/blocklist-clients.js", {});
+
 try {
   // AddonManager.jsm doesn't allow itself to be imported in the child
   // process. We're used in the child process (for now), so guard against
   // this.
   Components.utils.import("resource://gre/modules/AddonManager.jsm");
   /* globals AddonManagerPrivate*/
 } catch (e) {
 }
@@ -46,17 +48,17 @@ const FILE_BLOCKLIST                  = 
 const PREF_BLOCKLIST_LASTUPDATETIME   = "app.update.lastUpdateTime.blocklist-background-update-timer";
 const PREF_BLOCKLIST_URL              = "extensions.blocklist.url";
 const PREF_BLOCKLIST_ITEM_URL         = "extensions.blocklist.itemURL";
 const PREF_BLOCKLIST_ENABLED          = "extensions.blocklist.enabled";
 const PREF_BLOCKLIST_LEVEL            = "extensions.blocklist.level";
 const PREF_BLOCKLIST_PINGCOUNTTOTAL   = "extensions.blocklist.pingCountTotal";
 const PREF_BLOCKLIST_PINGCOUNTVERSION = "extensions.blocklist.pingCountVersion";
 const PREF_BLOCKLIST_SUPPRESSUI       = "extensions.blocklist.suppressUI";
-const PREF_ONECRL_VIA_AMO             = "security.onecrl.via.amo";
+const PREF_BLOCKLIST_VIA_AMO          = "security.blocklist.via.amo";
 const PREF_BLOCKLIST_UPDATE_ENABLED   = "services.blocklist.update_enabled";
 const PREF_APP_DISTRIBUTION           = "distribution.id";
 const PREF_APP_DISTRIBUTION_VERSION   = "distribution.version";
 const PREF_EM_LOGGING_ENABLED         = "extensions.logging.enabled";
 const XMLURI_BLOCKLIST                = "http://www.mozilla.org/2006/addons-blocklist";
 const XMLURI_PARSE_ERROR              = "http://www.mozilla.org/newlayout/xml/parsererror.xml"
 const URI_BLOCKLIST_DIALOG            = "chrome://mozapps/content/extensions/blocklist.xul"
 const DEFAULT_SEVERITY                = 3;
@@ -66,16 +68,17 @@ const SEVERITY_OUTDATED               = 
 const VULNERABILITYSTATUS_NONE             = 0;
 const VULNERABILITYSTATUS_UPDATE_AVAILABLE = 1;
 const VULNERABILITYSTATUS_NO_UPDATE        = 2;
 
 const EXTENSION_BLOCK_FILTERS = ["id", "name", "creator", "homepageURL", "updateURL"];
 
 var gLoggingEnabled = null;
 var gBlocklistEnabled = true;
+var gBlocklistFromXML = true;
 var gBlocklistLevel = DEFAULT_LEVEL;
 
 XPCOMUtils.defineLazyServiceGetter(this, "gConsole",
                                    "@mozilla.org/consoleservice;1",
                                    "nsIConsoleService");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gVersionChecker",
                                    "@mozilla.org/xpcom/version-comparator;1",
@@ -214,34 +217,32 @@ function restartApp() {
     return;
 
   var as = Cc["@mozilla.org/toolkit/app-startup;1"].
            getService(Ci.nsIAppStartup);
   as.quit(Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit);
 }
 
 /**
- * Checks whether this blocklist element is valid for the current OS and ABI.
- * If the element has an "os" attribute then the current OS must appear in
- * its comma separated list for the element to be valid. Similarly for the
- * xpcomabi attribute.
+ * Checks whether the current OS and ABI appears in the specified lists of
+ * values to match.
+ *
+ * @param  os
+ *         String with comma-separated OS values to match.
+ * @param  xpcomabi
+ *         String with comma-separated ABI values to match.
+ * @return Boolean whether it matches or not.
  */
-function matchesOSABI(blocklistElement) {
-  if (blocklistElement.hasAttribute("os")) {
-    var choices = blocklistElement.getAttribute("os").split(",");
-    if (choices.length > 0 && choices.indexOf(gApp.OS) < 0)
-      return false;
+function matchesOSABI(os, xpcomabi) {
+  if (os && !os.split(",").includes(gApp.OS)) {
+    return false;
   }
-
-  if (blocklistElement.hasAttribute("xpcomabi")) {
-    choices = blocklistElement.getAttribute("xpcomabi").split(",");
-    if (choices.length > 0 && choices.indexOf(gApp.XPCOMABI) < 0)
-      return false;
+  if (xpcomabi && !xpcomabi.split(",").includes(gApp.XPCOMABI)) {
+    return false;
   }
-
   return true;
 }
 
 /**
  * Gets the current value of the locale.  It's possible for this preference to
  * be localized, so we have to do a little extra work here.  Similar code
  * exists in nsHttpHandler.cpp when building the UA string.
  */
@@ -279,16 +280,17 @@ function parseRegExp(aStr) {
 
 function Blocklist() {
   this._preloadedBlocklistContent = new Map();
 
   Services.obs.addObserver(this, "xpcom-shutdown");
   Services.obs.addObserver(this, "sessionstore-windows-restored");
   gLoggingEnabled = getPref("getBoolPref", PREF_EM_LOGGING_ENABLED, false);
   gBlocklistEnabled = getPref("getBoolPref", PREF_BLOCKLIST_ENABLED, true);
+  gBlocklistFromXML = getPref("getBoolPref", PREF_BLOCKLIST_VIA_AMO, true);
   gBlocklistLevel = Math.min(getPref("getIntPref", PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
                                      MAX_BLOCK_LEVEL);
   gPref.addObserver("extensions.blocklist.", this);
   gPref.addObserver(PREF_EM_LOGGING_ENABLED, this);
   this.wrappedJSObject = this;
   // requests from child processes come in here, see receiveMessage.
   Services.ppmm.addMessageListener("Blocklist:getPluginBlocklistState", this);
   Services.ppmm.addMessageListener("Blocklist:content-blocklist-updated", this);
@@ -332,16 +334,21 @@ Blocklist.prototype = {
         case PREF_EM_LOGGING_ENABLED:
           gLoggingEnabled = getPref("getBoolPref", PREF_EM_LOGGING_ENABLED, false);
           break;
         case PREF_BLOCKLIST_ENABLED:
           gBlocklistEnabled = getPref("getBoolPref", PREF_BLOCKLIST_ENABLED, true);
           this._loadBlocklist();
           this._blocklistUpdated(null, null);
           break;
+        case PREF_BLOCKLIST_VIA_AMO:
+          gBlocklistFromXML = getPref("getBoolPref", PREF_BLOCKLIST_VIA_AMO, true);
+          this._loadBlocklist();
+          this._blocklistUpdated(null, null);
+          break;
         case PREF_BLOCKLIST_LEVEL:
           gBlocklistLevel = Math.min(getPref("getIntPref", PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
                                      MAX_BLOCK_LEVEL);
           this._blocklistUpdated(null, null);
           break;
       }
       break;
     case "sessionstore-windows-restored":
@@ -528,16 +535,22 @@ Blocklist.prototype = {
 
     return url;
   },
 
   notify(aTimer) {
     if (!gBlocklistEnabled)
       return;
 
+    // If blocklist does not rely on XML, do not download the file.
+    // Updates are performed in services/common/kinto-updater.js
+    if (!gBlocklistFromXML) {
+      return;
+    }
+
     try {
       var dsURI = gPref.getCharPref(PREF_BLOCKLIST_URL);
     } catch (e) {
       LOG("Blocklist::notify: The " + PREF_BLOCKLIST_URL + " preference" +
           " is missing!");
       return;
     }
 
@@ -704,44 +717,56 @@ Blocklist.prototype = {
    * Finds the newest blocklist file from the application and the profile and
    * load it or does nothing if neither exist.
    */
   _loadBlocklist() {
     if (!gBlocklistEnabled) {
       LOG("Blocklist::_loadBlocklistFromFile: blocklist is disabled");
       return;
     }
-    const content = this._loadBlocklistFromFile(FILE_BLOCKLIST);
-    this._loadBlocklistFromXMLString(content);
+
+    if (gBlocklistFromXML) {
+      const content = this._loadBlocklistFromFile(FILE_BLOCKLIST);
+      this._loadBlocklistFromXMLString(content);
+    } else {
+      // Rely on Kinto for blocklists synchronization.
+      // Load from distinct JSON files.
+      const addonsJson =  this._loadBlocklistFromFile(BlocklistClients.AddonBlocklistClient.filename);
+      const pluginsJson = this._loadBlocklistFromFile(BlocklistClients.PluginBlocklistClient.filename);
+      const gfxJson =     this._loadBlocklistFromFile(BlocklistClients.GfxBlocklistClient.filename);
+      this._loadBlocklistFromJSONStrings(addonsJson, pluginsJson, gfxJson);
+      // Certificates revocation happens directly in OneCRLBlocklistClient from
+      // services/common/blocklist-clients.js.
+    }
   },
 
   _loadBlocklistFromFile(filename) {
-    let file = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    const {components: filePath} = OS.Path.split(filename);
+    let file = FileUtils.getFile(KEY_PROFILEDIR, filePath);
     if (!file.exists()) {
-      let appFile = FileUtils.getFile(KEY_APPDIR, [filename]);
-      if (appFile.exists()) {
-        file = appFile;
+      file = FileUtils.getFile(KEY_APPDIR, filePath);
+      if (!file.exists()) {
+        file = FileUtils.getFile(KEY_APPDIR, ["defaults"].concat(filePath));
+        if (!file.exists()) {
+          LOG("Blocklist::_loadBlocklistFromFile: File does not exist " + filename);
+          return "";
+        }
       }
     }
 
     let telemetry = Services.telemetry;
 
     // Check if preloaded content exists for this file.
     if (this._preloadedBlocklistContent.has(file.path)) {
       telemetry.getHistogramById("BLOCKLIST_SYNC_FILE_LOAD").add(false);
       const text = this._preloadedBlocklistContent.get(file.path);
       this._preloadedBlocklistContent.delete(file.path);
       return text;
     }
 
-    if (!file.exists()) {
-      LOG("Blocklist::_loadBlocklistFromFile: File does not exist " + file.path);
-      return;
-    }
-
     telemetry.getHistogramById("BLOCKLIST_SYNC_FILE_LOAD").add(true);
 
     let text = "";
     let fstream = null;
     let cstream = null;
 
     try {
       fstream = Components.classes["@mozilla.org/network/file-input-stream;1"]
@@ -784,52 +809,56 @@ Blocklist.prototype = {
   },
 
   async _preloadBlocklist() {
     if (!gBlocklistEnabled) {
       LOG("Blocklist::_preloadBlocklist: blocklist is disabled");
       return;
     }
 
-    await this._preloadBlocklistFile(FILE_BLOCKLIST);
-  }),
+    if (gBlocklistFromXML) {
+      await this._preloadBlocklistFile(FILE_BLOCKLIST);
+    } else {
+      await this._preloadBlocklistFile(BlocklistClients.AddonBlocklistClient.filename);
+      await this._preloadBlocklistFile(BlocklistClients.PluginBlocklistClient.filename);
+      await this._preloadBlocklistFile(BlocklistClients.GfxBlocklistClient.filename);
+    }
+  },
 
   async _preloadBlocklistFile(filename) {
-    let file = FileUtils.getFile(KEY_PROFILEDIR, [filename]);
+    const {components: filePath} = OS.Path.split(filename);
+    let file = FileUtils.getFile(KEY_PROFILEDIR, filePath);
     if (!file.exists()) {
-      let appFile = FileUtils.getFile(KEY_APPDIR, [filename]);
-      if (appFile.exists()) {
-        file = appFile;
-      } else {
-        LOG(`Blocklist::_preloadBlocklistFile: no ${filename} file found`);
-        return;
+      file = FileUtils.getFile(KEY_APPDIR, filePath);
+      if (!file.exists()) {
+        file = FileUtils.getFile(KEY_APPDIR, ["defaults"].concat(filePath));
+        if (!file.exists()) {
+          LOG(`Blocklist::_preloadBlocklistFile: no ${filename} file found`);
+          return;
+        }
       }
     }
     const path = file.path;
     if (this._preloadedBlocklistContent.has(path)) {
       // The file has been already loaded.
       return;
     }
 
     try {
       let content = await OS.File.read(path, { encoding: "utf-8" });
 
       if (!this._isBlocklistLoaded()) {
         // Store the content only if a sync load has not been performed in the meantime.
         this._preloadedBlocklistContent.set(path, content);
       }
     } catch (e) {
-      LOG(`Blocklist::_preloadBlocklistFile: Failed to load ${path} file : ${e}`);
+      LOG(`Blocklist::_preloadBlocklist: Failed to load ${path} file : ${e}`);
     }
   },
 
-  _isBlocklistLoaded() {
-    return this._addonEntries != null && this._gfxEntries != null && this._pluginEntries != null;
-  },
-
   /**
 #    The blocklist XML file looks something like this:
 #
 #    <blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
 #      <emItems>
 #        <emItem id="item_1@domain" blockID="i1">
 #          <prefs>
 #            <pref>accessibility.accesskeycausesactivation</pref>
@@ -901,47 +930,45 @@ Blocklist.prototype = {
       var doc = parser.parseFromString(text, "text/xml");
       if (doc.documentElement.namespaceURI != XMLURI_BLOCKLIST) {
         LOG("Blocklist::_loadBlocklistFromXMLString: aborting due to incorrect " +
             "XML Namespace.\r\nExpected: " + XMLURI_BLOCKLIST + "\r\n" +
             "Received: " + doc.documentElement.namespaceURI);
         return;
       }
 
-      var populateCertBlocklist = getPref("getBoolPref", PREF_ONECRL_VIA_AMO, true);
-
       var childNodes = doc.documentElement.childNodes;
       for (let element of childNodes) {
         if (!(element instanceof Ci.nsIDOMElement))
           continue;
         switch (element.localName) {
         case "emItems":
           this._addonEntries = this._processItemNodes(element.childNodes, "emItem",
                                                       this._handleEmItemNode);
           break;
         case "pluginItems":
           this._pluginEntries = this._processItemNodes(element.childNodes, "pluginItem",
                                                        this._handlePluginItemNode);
           break;
         case "certItems":
-          if (populateCertBlocklist) {
+          if (gBlocklistFromXML) {
             this._processItemNodes(element.childNodes, "certItem",
                                    this._handleCertItemNode.bind(this));
           }
           break;
         case "gfxItems":
           // Parse as simple list of objects.
           this._gfxEntries = this._processItemNodes(element.childNodes, "gfxBlacklistEntry",
                                                     this._handleGfxBlacklistNode);
           break;
         default:
           LOG("Blocklist::_loadBlocklistFromXMLString: ignored entries " + element.localName);
         }
       }
-      if (populateCertBlocklist) {
+      if (gBlocklistFromXML) {
         gCertBlocklistService.saveEntries();
       }
       if (this._gfxEntries.length > 0) {
         this._notifyObserversBlocklistGFX();
       }
     } catch (e) {
       LOG("Blocklist::_loadBlocklistFromXMLString: Error constructing blocklist " + e);
     }
@@ -983,18 +1010,20 @@ Blocklist.prototype = {
         gCertBlocklistService.revokeCertBySubjectAndPubKey(subject, pubKeyHash);
       } catch (e) {
         LOG("Blocklist::_handleCertItemNode: Error adding revoked cert by Subject and PubKey" + e);
       }
     }
   },
 
   _handleEmItemNode(blocklistElement, result) {
-    if (!matchesOSABI(blocklistElement))
+    if (!matchesOSABI(blocklistElement.getAttribute("os"),
+                      blocklistElement.getAttribute("xpcomabi"))) {
       return;
+    }
 
     let blockEntry = {
       versions: [],
       prefs: [],
       blockID: null,
       attributes: new Map()
       // Atleast one of EXTENSION_BLOCK_FILTERS must get added to attributes
     };
@@ -1035,18 +1064,20 @@ Blocklist.prototype = {
       blockEntry.versions.push(new BlocklistItemData(null));
 
     blockEntry.blockID = blocklistElement.getAttribute("blockID");
 
     result.push(blockEntry);
   },
 
   _handlePluginItemNode(blocklistElement, result) {
-    if (!matchesOSABI(blocklistElement))
+    if (!matchesOSABI(blocklistElement.getAttribute("os"),
+                      blocklistElement.getAttribute("xpcomabi"))) {
       return;
+    }
 
     var matchNodes = blocklistElement.childNodes;
     var blockEntry = {
       matches: {},
       versions: [],
       blockID: null,
       infoURL: null,
     };
@@ -1146,16 +1177,200 @@ Blocklist.prototype = {
       }
       if (value) {
         blockEntry[matchElement.localName] = value;
       }
     }
     result.push(blockEntry);
   },
 
+  _loadBlocklistFromJSONStrings(addonsJson, pluginsJson, gfxJson) {
+    const blocklists = [
+      {content: addonsJson,  handle: this._handleAddonItemJSON,  dest: "_addonEntries"},
+      {content: pluginsJson, handle: this._handlePluginItemJSON, dest: "_pluginEntries"},
+      {content: gfxJson,     handle: this._handleGfxItemJSON,    dest: "_gfxEntries"}
+    ];
+
+    for (let {content, handle, dest} of blocklists) {
+      this[dest] = [];
+      if (!content) {
+        continue;
+      }
+      let parsed;
+      try {
+        parsed = JSON.parse(content);
+        this[dest] = parsed.data.map((entry) => handle(entry)).filter((entry) => entry);
+      } catch (e) {
+        LOG(`Blocklist::_loadBlocklistFromJSONStrings: Could not parse JSON for ${dest}: ${e}`);
+      }
+    }
+  },
+
+  _handleAddonItemJSON(data) {
+    /*
+    {
+      "prefs": [],
+      "blockID": "i446",
+      "last_modified": 1457434834683,
+      "versionRange": [{
+        "targetApplication": [],
+        "maxVersion": "*",
+        "minVersion": "0",
+        "severity": "1"
+      }],
+      "guid": "{E90FA778-C2B7-41D0-9FA9-3FEC1CA54D66}",
+      "id": "87a5dc56-1fec-ebf2-a09b-6f2cbd4eb2d3",
+      "os": "Linux,WINNT"
+    }
+    */
+    if (!matchesOSABI(data.os, data.xpcomabi)) {
+      return null;
+    }
+
+    const blockEntry = {
+      versions: [],
+      prefs: [],
+      blockID: null,
+      attributes: new Map()
+      // At least one of EXTENSION_BLOCK_FILTERS must get added to attributes
+    };
+
+    // Any filter starting with '/' is interpreted as a regex. So if an attribute
+    // starts with a '/' it must be checked via a regex.
+    function regExpCheck(attr) {
+      return attr.startsWith("/") ? parseRegExp(attr) : attr;
+    }
+
+    for (let filter of EXTENSION_BLOCK_FILTERS) {
+      // In JSON, addon `id` is `guid`.
+      let attr = data[filter == "id" ? "guid" : filter];
+      if (attr) {
+        blockEntry.attributes.set(filter, regExpCheck(attr));
+      }
+    }
+
+    blockEntry.prefs = data.prefs;
+    blockEntry.blockID = data.blockID || data.id;  // Fallback to the JSON record id.
+
+    for (let versionRange of data.versionRange) {
+      const itemData = new BlocklistItemData(null);
+      const fields = ["minVersion", "maxVersion", "severity", "vulnerabilityStatus"];
+      for (let field of fields) {
+        if (versionRange[field]) {
+          itemData[field] = versionRange[field];
+        }
+      }
+      for (let targetApplication of versionRange.targetApplication) {
+        // default to the current application if id is not provided.
+        const appId = targetApplication.guid || gApp.ID;
+        itemData.targetApps[appId] = targetApplication;
+      }
+      blockEntry.versions.push(itemData);
+    }
+    if (blockEntry.versions.length == 0) {
+      blockEntry.versions.push(new BlocklistItemData(null));
+    }
+
+    return blockEntry;
+  },
+
+  _handlePluginItemJSON(data) {
+    /*
+    {
+      "matchFilename": "JavaPlugin2_NPAPI\\.plugin",
+      "blockID": "p123",
+      "id": "bdcf0717-a873-adbf-7603-83a49fb996bc",
+      "last_modified": 1457434851748,
+      "versionRange": [{
+        "targetApplication": [{
+          "minVersion": "0.1",
+          "guid": "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}",
+          "maxVersion": "17.*"
+        }],
+        "maxVersion": "14.2.0",
+        "minVersion": "0",
+        "severity": "1"
+      }],
+      "os": "WINNT",
+      "xpcomabi": "x86"
+    }
+    */
+    if (!matchesOSABI(data.os, data.xpcomabi)) {
+      return null;
+    }
+
+    const blockEntry = {
+      matches: {},
+      versions: [],
+      blockID: null,
+      infoURL: null,
+    };
+
+    blockEntry.infoURL = data.infoURL;
+    blockEntry.blockID = data.blockID || data.id;  // Fallback to the JSON record id.
+
+    // Verify Matches
+    var hasMatch = false;
+    for (let prop of ["Name", "Description", "Filename"]) {
+      if (data.hasOwnProperty(`match${prop}`)) {
+        try {
+          blockEntry.matches[prop.toLowerCase()] = new RegExp(data[`match${prop}`], "m");
+          hasMatch = true;
+        } catch (e) {
+          // Ignore invalid regular expressions
+        }
+      }
+    }
+    // Plugin entries require *something* to match to an actual plugin
+    if (!hasMatch) {
+      LOG(`Blocklist::_handlePluginItemJSON: Ignored entry ${blockEntry.blockID} with no valid match attribute.`);
+      return null;
+    }
+
+    for (let versionRange of data.versionRange) {
+      const itemData = new BlocklistItemData(null);
+      const fields = ["minVersion", "maxVersion", "severity"];
+      for (let field of fields) {
+        if (versionRange[field])
+          itemData[field] = versionRange[field];
+      }
+      for (let targetApplication of versionRange.targetApplication) {
+        // default to the current application if id is not provided.
+        const appId = targetApplication.guid || gApp.ID;
+        itemData.targetApps[appId] = targetApplication;
+      }
+      blockEntry.versions.push(itemData);
+    }
+    // Add a default versionRange if there wasn't one specified
+    if (blockEntry.versions.length == 0)
+      blockEntry.versions.push(new BlocklistItemData(null));
+
+    return blockEntry;
+  },
+
+  _handleGfxItemJSON(data) {
+    /*
+    {
+      "driverVersionComparator": "LESS_THAN_OR_EQUAL",
+      "driverVersion": "8.17.12.5896",
+      "vendor": "0x10de",
+      "blockID": "g36",
+      "feature": "DIRECT3D_9_LAYERS",
+      "devices": ["0x0a6c"],
+      "featureStatus": "BLOCKED_DRIVER_VERSION",
+      "last_modified": 1458035931837,
+      "os": "WINNT 6.1",
+      "id": "3f947f16-37c2-4e96-d356-78b26363729b"
+    }
+    */
+    const blockEntry = Object.assign({}, data);
+    blockEntry.blockID = data.blockID || data.id;  // Fallback to the JSON record id.
+    return blockEntry;
+  },
+
   /* See nsIBlocklistService */
   getPluginBlocklistState(plugin, appVersion, toolkitVersion) {
     if (AppConstants.platform == "android") {
       return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
     }
     if (!this._isBlocklistLoaded())
       this._loadBlocklist();
     return this._getPluginBlocklistState(plugin, this._pluginEntries,
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_json/sample.json
@@ -0,0 +1,50 @@
+{
+    "data": [
+        {
+            "blockID": "i53923",
+            "guid": "LionKiller@jetpack",
+            "id": "9d500963-d45e-3a91-6e32-66f3811b99cc",
+            "last_modified": 1457434774040,
+            "prefs": [],
+            "versionRange": [
+                {
+                    "maxVersion": "*",
+                    "minVersion": "0",
+                    "severity": "3",
+                    "targetApplication": []
+                }
+            ]
+        },
+
+        {
+            "blockID": "i539",
+            "guid": "ScorpionSaver@jetpack",
+            "id": "9d500963-d80e-3a91-6e74-66f3811b99cc",
+            "last_modified": 1457434774040,
+            "prefs": [],
+            "versionRange": [
+                {
+                    "maxVersion": "*",
+                    "minVersion": "0",
+                    "severity": "1",
+                    "targetApplication": []
+                }
+            ]
+        },
+        {
+            "blockID": "i808",
+            "guid": "{c96d1ae6-c4cf-4984-b110-f5f561b33b5a}",
+            "id": "9ccfac91-e463-c30c-f0bd-14143794a8dd",
+            "last_modified": 1457434774125,
+            "prefs": [],
+            "versionRange": [
+                {
+                    "maxVersion": "*",
+                    "minVersion": "0",
+                    "severity": "3",
+                    "targetApplication": []
+                }
+            ]
+        }
+    ]
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_blocklist_json.js
@@ -0,0 +1,380 @@
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
+
+const PREF_BLOCKLIST_VIA_AMO = "security.blocklist.via.amo";
+const KEY_APPDIR             = "XCurProcD";
+const KEY_PROFILEDIR         = "ProfD";
+const TEST_APP_ID            = "xpcshell@tests.mozilla.org";
+
+
+const SAMPLE_FILE = do_get_file("data/test_blocklist_json/sample.json");
+
+const SAMPLE_ADDON_RECORD = {
+  "prefs": [],
+  "blockID": "i446",
+  "last_modified": 1457434834683,
+  "versionRange": [{
+    "targetApplication": [{
+      "minVersion": "0.1",
+      "guid": "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}",
+      "maxVersion": "17.*"
+    }],
+    "maxVersion": "*",
+    "minVersion": "0",
+    "severity": "1",
+    "vulnerabilityStatus": "0"
+  }],
+  "guid": "{E90FA778-C2B7-41D0-9FA9-3FEC1CA54D66}",
+  "id": "87a5dc56-1fec-ebf2-a09b-6f2cbd4eb2d3"
+};
+
+const SAMPLE_PLUGIN_RECORD = {
+  "matchFilename": "JavaPlugin2_NPAPI\\.plugin",
+  "blockID": "p123",
+  "id": "bdcf0717-a873-adbf-7603-83a49fb996bc",
+  "last_modified": 1457434851748,
+  "infoURL": "https://addons.mozilla.org/en-US/firefox/blocked/p123",
+  "versionRange": [{
+    "targetApplication": [{
+      "minVersion": "0.1",
+      "guid": "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}",
+      "maxVersion": "17.*"
+    }],
+    "maxVersion": "14.2.0",
+    "minVersion": "0",
+    "severity": "1"
+  }]
+};
+
+const SAMPLE_GFX_RECORD = {
+  "driverVersionComparator": "LESS_THAN_OR_EQUAL",
+  "driverVersion": "8.17.12.5896",
+  "vendor": "0x10de",
+  "blockID": "g36",
+  "feature": "DIRECT3D_9_LAYERS",
+  "devices": ["0x0a6c"],
+  "featureStatus": "BLOCKED_DRIVER_VERSION",
+  "last_modified": 1458035931837,
+  "os": "WINNT 6.1",
+  "id": "3f947f16-37c2-4e96-d356-78b26363729b"
+};
+
+
+function clearProfile(name) {
+  let filename = name + ".json";
+  let blocklist = FileUtils.getFile(KEY_PROFILEDIR, ["blocklists", filename]);
+  if (blocklist.exists())
+    blocklist.remove(true);
+}
+
+
+function copyToProfile(file, name) {
+  const gProfDir = FileUtils.getFile(KEY_PROFILEDIR, ["blocklists"]);
+  let filename = name + ".json";
+  file = file.clone();
+  file.copyTo(gProfDir, filename);
+  file = gProfDir.clone();
+  file.append(filename);
+}
+
+
+function Blocklist() {
+  let blocklist = Cc["@mozilla.org/extensions/blocklist;1"].
+                  getService().wrappedJSObject;
+  blocklist._clear();
+  return blocklist;
+}
+
+
+function run_test() {
+  // Some blocklist code rely on gApp.ID.
+  createAppInfo(TEST_APP_ID, "XPCShell", "1", "1");
+  // Disable blocklist via AMO.
+  Services.prefs.setBoolPref(PREF_BLOCKLIST_VIA_AMO, false);
+
+  // Starts addons manager.
+  startupManager();
+
+  // Clean-up for profile data.
+  do_register_cleanup(function() {
+    for (let filename of ["addons.json", "plugins.json"]) {
+      const file = FileUtils.getFile(KEY_PROFILEDIR, ["blocklists", filename]);
+      if (file.exists()) file.remove(true);
+    }
+  });
+
+  run_next_test();
+}
+
+
+add_task(function* test_addon_entry_from_json_simple() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_ADDON_RECORD);
+
+  const entry = blocklist._handleAddonItemJSON(data);
+
+  equal(entry.blockID, SAMPLE_ADDON_RECORD.blockID);
+  equal(entry.prefs, SAMPLE_ADDON_RECORD.prefs);
+  equal(entry.attributes.get("id"), SAMPLE_ADDON_RECORD.guid);
+  equal(entry.versions.length, 1);
+  const item = entry.versions[0];
+  equal(item.minVersion, "0");
+  equal(item.maxVersion, "*");
+  equal(item.severity, "1");
+  equal(item.vulnerabilityStatus, "0");
+  equal(item.targetApps["{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"].minVersion, "0.1");
+  equal(item.targetApps["{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"].maxVersion, "17.*");
+});
+
+
+add_task(function* test_addon_entry_from_json_no_version_range() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_ADDON_RECORD);
+  data.versionRange = [];
+
+  const entry = blocklist._handleAddonItemJSON(data);
+
+  equal(entry.versions.length, 1);
+  const item = entry.versions[0];
+  equal(item.minVersion, null);
+  equal(item.maxVersion, null);
+  equal(item.severity, 3);
+  equal(item.vulnerabilityStatus, 0);
+  equal(item.targetApps[TEST_APP_ID].minVersion, null);
+  equal(item.targetApps[TEST_APP_ID].maxVersion, null);
+});
+
+
+add_task(function* test_addon_entry_from_json_without_blockid() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_ADDON_RECORD);
+  delete data.blockID;
+
+  const entry = blocklist._handleAddonItemJSON(data);
+
+  equal(entry.blockID, SAMPLE_ADDON_RECORD.id);
+});
+
+
+add_task(function* test_addon_entry_without_target_application() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_ADDON_RECORD);
+  data.versionRange[0].targetApplication = [];
+
+  const entry = blocklist._handleAddonItemJSON(data);
+
+  const item = entry.versions[0];
+  equal(item.minVersion, "0");
+  equal(item.maxVersion, "*");
+  equal(item.severity, "1");
+  equal(item.targetApps[TEST_APP_ID].minVersion, null);
+  equal(item.targetApps[TEST_APP_ID].maxVersion, null);
+});
+
+
+add_task(function* test_plugin_entry_from_json_simple() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_PLUGIN_RECORD);
+
+  const entry = blocklist._handlePluginItemJSON(data);
+
+  equal(entry.blockID, SAMPLE_PLUGIN_RECORD.blockID);
+  equal(entry.infoURL, SAMPLE_PLUGIN_RECORD.infoURL);
+  equal(entry.matches["filename"].constructor.name, "RegExp");
+  equal(entry.versions.length, 1);
+  const item = entry.versions[0];
+  equal(item.minVersion, "0");
+  equal(item.maxVersion, "14.2.0");
+  equal(item.severity, "1");
+  equal(item.targetApps["{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"].minVersion, "0.1");
+  equal(item.targetApps["{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"].maxVersion, "17.*");
+});
+
+
+add_task(function* test_plugin_entry_from_json_no_match() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_PLUGIN_RECORD);
+  delete data.matchFilename;
+
+  const entry = blocklist._handlePluginItemJSON(data);
+
+  equal(entry, undefined);
+});
+
+
+add_task(function* test_plugin_entry_from_json_no_version_range() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_PLUGIN_RECORD);
+  data.versionRange = [];
+
+  const entry = blocklist._handlePluginItemJSON(data);
+
+  equal(entry.versions.length, 1);
+  const item = entry.versions[0];
+  equal(item.minVersion, null);
+  equal(item.maxVersion, null);
+  equal(item.severity, 3);
+  equal(item.targetApps[TEST_APP_ID].minVersion, null);
+  equal(item.targetApps[TEST_APP_ID].maxVersion, null);
+});
+
+
+add_task(function* test_plugin_entry_from_json_without_blockid() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_PLUGIN_RECORD);
+  delete data.blockID;
+
+  const entry = blocklist._handlePluginItemJSON(data);
+
+  equal(entry.blockID, SAMPLE_PLUGIN_RECORD.id);
+});
+
+
+add_task(function* test_plugin_entry_without_target_application() {
+  const blocklist = Blocklist();
+  const data = Object.assign({}, SAMPLE_PLUGIN_RECORD);
+  data.versionRange[0].targetApplication = [];
+
+  const entry = blocklist._handlePluginItemJSON(data);
+
+  const item = entry.versions[0];
+  equal(item.minVersion, "0");
+  equal(item.maxVersion, "14.2.0");
+  equal(item.severity, "1");
+  equal(item.targetApps[TEST_APP_ID].minVersion, null);
+  equal(item.targetApps[TEST_APP_ID].maxVersion, null);
+});
+
+
+add_task(function* test_is_loaded_synchronously() {
+  const blocklist = Blocklist();
+  strictEqual(blocklist._isBlocklistLoaded(), false);
+  // Calls synchronous method from Interface.
+  blocklist.isAddonBlocklisted("addon", "appVersion", "toolkitVersion");
+  strictEqual(blocklist._isBlocklistLoaded(), true);
+});
+
+
+add_task(function* test_notify_does_not_download_xml_file() {
+  const blocklist = Blocklist();
+  strictEqual(blocklist._isBlocklistLoaded(), false);
+  // When managed with Kinto, nothing is loaded/downloaded on notify.
+  blocklist.notify(null);
+  strictEqual(blocklist._isBlocklistLoaded(), false);
+});
+
+
+add_task(function* preload_json_async() {
+  const blocklist = Blocklist();
+
+  yield blocklist._preloadBlocklist();
+
+  // Preloaded content comes from app dir.
+  const addonsAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "addons.json"]).path;
+  const gfxAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "gfx.json"]).path;
+  const pluginsAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "plugins.json"]).path;
+  ok(blocklist._preloadedBlocklistContent.get(addonsAppPath).length > 0);
+  ok(blocklist._preloadedBlocklistContent.get(gfxAppPath).length > 0);
+  ok(blocklist._preloadedBlocklistContent.get(pluginsAppPath).length > 0);
+});
+
+
+add_task(function* preload_json_reads_profile_data() {
+  const blocklist = Blocklist();
+
+  // Write some JSON content in profile dir.
+  copyToProfile(SAMPLE_FILE, "addons");
+
+  try {
+    yield blocklist._preloadBlocklist();
+    // Preloaded content comes from profile dir file.
+    const path = FileUtils.getFile(KEY_PROFILEDIR, ["blocklists", "addons.json"]).path;
+    let fileContent = blocklist._preloadedBlocklistContent.get(path);
+    strictEqual(fileContent.length > 0, true);
+    strictEqual(JSON.parse(fileContent).data[0].blockID, "i53923");
+  } finally {
+    // Clean-up: delete created file.
+    clearProfile("addons");
+  }
+});
+
+
+add_task(function* load_uses_preloaded_json_if_available() {
+  clearProfile("addons");
+  clearProfile("gfx");
+  clearProfile("plugins");
+
+  const blocklist = Blocklist();
+
+  // Simulate preload of data.
+  const addonsAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "addons.json"]).path;
+  const gfxAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "gfx.json"]).path;
+  const pluginsAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "plugins.json"]).path;
+  blocklist._preloadedBlocklistContent.set(addonsAppPath, JSON.stringify({data: [SAMPLE_ADDON_RECORD]}));
+  blocklist._preloadedBlocklistContent.set(gfxAppPath, JSON.stringify({data: [SAMPLE_GFX_RECORD]}));
+  blocklist._preloadedBlocklistContent.set(pluginsAppPath, JSON.stringify({data: [SAMPLE_PLUGIN_RECORD]}));
+
+  blocklist._loadBlocklist();
+
+  // Data loaded comes from preloaded content.
+  equal(blocklist._addonEntries[0].blockID, SAMPLE_ADDON_RECORD.blockID);
+  equal(blocklist._gfxEntries[0].blockID, SAMPLE_GFX_RECORD.blockID);
+  equal(blocklist._pluginEntries[0].blockID, SAMPLE_PLUGIN_RECORD.blockID);
+});
+
+
+add_task(function* test_read_json_from_app_or_profile() {
+  const blocklist = Blocklist();
+
+  // Reads from app dir by default.
+  clearProfile("addons");
+  blocklist._loadBlocklist();
+  ok(blocklist._addonEntries.length >= 416);
+
+  // Reads from profile if present.
+  copyToProfile(SAMPLE_FILE, "addons");
+  try {
+    blocklist._loadBlocklist();
+    equal(blocklist._addonEntries.length, 3);
+  } finally {
+    clearProfile("addons");
+  }
+});
+
+
+add_task(function* test_invalid_preloaded_json() {
+  const blocklist = Blocklist();
+
+  // Simulate invalid json in preloaded data from app dir:
+  const addonsAppPath = FileUtils.getFile(KEY_APPDIR, ["defaults", "blocklists", "addons.json"]).path;
+  blocklist._preloadedBlocklistContent.set(addonsAppPath, "{>invalid}");
+
+  blocklist._loadBlocklist();
+
+  // TODO: Bug 1360576 fallback on release dumps instead of giving up.
+  deepEqual(blocklist._addonEntries, []);
+});
+
+
+add_task(function* test_invalid_json_file() {
+  // Write invalid JSON file in profile (as failed download or whatever)
+  const path = OS.Path.join(OS.Constants.Path.profileDir, "blocklists", "addons.json");
+  yield OS.File.writeAtomic(path, "{>invalid}", {tmpPath: path + ".tmp"});
+
+  const blocklist = Blocklist();
+  try {
+    blocklist._loadBlocklist();
+    // TODO: Bug 1360576 fallback on release dumps instead of giving up.
+    deepEqual(blocklist._addonEntries.length, 0);
+  } finally {
+    clearProfile("addons");
+  }
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -9,16 +9,18 @@ support-files =
   xpcshell-shared.ini
 
 [test_addon_path_service.js]
 [test_addonStartup.js]
 [test_asyncBlocklistLoad.js]
 tags = blocklist
 [test_blocklist_gfx.js]
 tags = blocklist
+[test_blocklist_json.js]
+tags = blocklist
 [test_cache_certdb.js]
 run-if = addon_signing
 [test_cacheflush.js]
 [test_DeferredSave.js]
 [test_gmpProvider.js]
 skip-if = appname != "firefox"
 [test_hotfix_cert.js]
 [test_isReady.js]