Bug 1306470 - Create a services client for augmenting the PKP preload list between releases. r=leplatrem draft
authorMark Goodwin <mgoodwin@mozilla.com>
Fri, 16 Dec 2016 16:34:42 +0000
changeset 450441 90a5a242177d4d8826c80e6c1f424805b62608d4
parent 449439 1ea0c60db5d25a7d522e2f252c1978ff4fc7538e
child 539747 e42b01228f35ec65444c5ea9588fa4844ac2a18a
push id38851
push usermgoodwin@mozilla.com
push dateFri, 16 Dec 2016 16:36:40 +0000
reviewersleplatrem
bugs1306470, 1306471
milestone53.0a1
Bug 1306470 - Create a services client for augmenting the PKP preload list between releases. r=leplatrem This makes use of the recent changes to the nsISiteSecurityService (from bug 1306471) to provide a self-updating public key preload list. MozReview-Commit-ID: 4s3LORibAN1
modules/libpref/init/all.js
services/common/blocklist-clients.js
services/common/blocklist-updater.js
services/common/tests/unit/test_blocklist_pinning.js
services/common/tests/unit/test_blocklist_updater.js
services/common/tests/unit/xpcshell.ini
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -2209,16 +2209,20 @@ pref("extensions.blocklist.level", 2);
 pref("services.blocklist.changes.path", "/buckets/monitor/collections/changes/records");
 pref("services.blocklist.bucket", "blocklists");
 pref("services.blocklist.onecrl.collection", "certificates");
 pref("services.blocklist.onecrl.checked", 0);
 pref("services.blocklist.addons.collection", "addons");
 pref("services.blocklist.addons.checked", 0);
 pref("services.blocklist.plugins.collection", "plugins");
 pref("services.blocklist.plugins.checked", 0);
+pref("services.blocklist.pinning.enabled", true);
+pref("services.blocklist.pinning.bucket", "pinning");
+pref("services.blocklist.pinning.collection", "pins");
+pref("services.blocklist.pinning.checked", 0);
 pref("services.blocklist.gfx.collection", "gfx");
 pref("services.blocklist.gfx.checked", 0);
 
 // Controls whether signing should be enforced on signature-capable blocklist
 // collections.
 pref("services.blocklist.signing.enforced", true);
 
 // Enable blocklists via the services settings mechanism
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["AddonBlocklistClient",
                          "GfxBlocklistClient",
                          "OneCRLBlocklistClient",
                          "PluginBlocklistClient",
+                         "PinningBlocklistClient",
                          "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");
@@ -27,16 +28,20 @@ const { CanonicalJSON } = Components.uti
 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
@@ -77,37 +82,37 @@ function fetchRemoteCollection(collectio
            .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(connection) {
+function kintoClient(connection, bucket) {
   let base = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
-  let bucket = Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET);
 
   let config = {
     remote: base,
     bucket: bucket,
     adapter: FirefoxAdapter,
     adapterOptions: {sqliteHandle: connection},
   };
 
   return new Kinto(config);
 }
 
 
 class BlocklistClient {
 
-  constructor(collectionName, lastCheckTimePref, processCallback, signerName) {
+  constructor(collectionName, lastCheckTimePref, processCallback, bucketName, signerName) {
     this.collectionName = collectionName;
     this.lastCheckTimePref = lastCheckTimePref;
     this.processCallback = processCallback;
+    this.bucketName = bucketName;
     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());
@@ -163,17 +168,17 @@ class BlocklistClient {
       }
     }
 
 
     return Task.spawn((function* syncCollection() {
       let connection;
       try {
         connection = yield FirefoxAdapter.openConnection({path: KINTO_STORAGE_PATH});
-        let db = kintoClient(connection);
+        let db = kintoClient(connection, this.bucketName);
         let collection = db.collection(this.collectionName, opts);
 
         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;
@@ -242,25 +247,65 @@ function* updateCertBlocklist(records) {
         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.
+      // 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)) {
+    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 (pinType == "KeyPin" && pins.length &&
+            versions.indexOf(appInfo.version) != -1) {
+          siteSecurityService.setKeyPins(item.hostName,
+              item.includeSubdomains,
+              item.expires,
+              pins.length,
+              pins, true);
+        }
+      } catch (e) {
+        // prevent errors relating to individual preload entries from causing
+        // sync to fail. We will accumulate telemetry for such failures in bug
+        // 1254099.
+      }
+    }
+  } else {
+    return;
+  }
+}
+
+/**
  * 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);
@@ -271,33 +316,44 @@ function* updateJSONBlocklist(filename, 
     // 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,
+  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,
-  updateJSONBlocklist.bind(undefined, FILENAME_ADDONS_JSON)
+  updateJSONBlocklist.bind(undefined, FILENAME_ADDONS_JSON),
+  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
 );
 
 this.GfxBlocklistClient = new BlocklistClient(
   Services.prefs.getCharPref(PREF_BLOCKLIST_GFX_COLLECTION),
   PREF_BLOCKLIST_GFX_CHECKED_SECONDS,
-  updateJSONBlocklist.bind(undefined, FILENAME_GFX_JSON)
+  updateJSONBlocklist.bind(undefined, FILENAME_GFX_JSON),
+  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
 );
 
 this.PluginBlocklistClient = new BlocklistClient(
   Services.prefs.getCharPref(PREF_BLOCKLIST_PLUGINS_COLLECTION),
   PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS,
-  updateJSONBlocklist.bind(undefined, FILENAME_PLUGINS_JSON)
+  updateJSONBlocklist.bind(undefined, FILENAME_PLUGINS_JSON),
+  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"
+);
--- a/services/common/blocklist-updater.js
+++ b/services/common/blocklist-updater.js
@@ -8,27 +8,27 @@ const { classes: Cc, Constructor: CC, in
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.importGlobalProperties(['fetch']);
 const BlocklistClients = Cu.import("resource://services-common/blocklist-clients.js", {});
 
 const PREF_SETTINGS_SERVER              = "services.settings.server";
 const PREF_BLOCKLIST_CHANGES_PATH       = "services.blocklist.changes.path";
-const PREF_BLOCKLIST_BUCKET             = "services.blocklist.bucket";
 const PREF_BLOCKLIST_LAST_UPDATE        = "services.blocklist.last_update_seconds";
 const PREF_BLOCKLIST_LAST_ETAG          = "services.blocklist.last_etag";
 const PREF_BLOCKLIST_CLOCK_SKEW_SECONDS = "services.blocklist.clock_skew_seconds";
 
 
 const gBlocklistClients = {
   [BlocklistClients.OneCRLBlocklistClient.collectionName]: BlocklistClients.OneCRLBlocklistClient,
   [BlocklistClients.AddonBlocklistClient.collectionName]: BlocklistClients.AddonBlocklistClient,
   [BlocklistClients.GfxBlocklistClient.collectionName]: BlocklistClients.GfxBlocklistClient,
-  [BlocklistClients.PluginBlocklistClient.collectionName]: BlocklistClients.PluginBlocklistClient
+  [BlocklistClients.PluginBlocklistClient.collectionName]: BlocklistClients.PluginBlocklistClient,
+  [BlocklistClients.PinningPreloadClient.collectionName]: BlocklistClients.PinningPreloadClient
 };
 
 // Add a blocklist client for testing purposes. Do not use for any other purpose
 this.addTestBlocklistClient = (name, client) => { gBlocklistClients[name] = client; }
 
 // This is called by the ping mechanism.
 // returns a promise that rejects if something goes wrong
 this.checkVersions = function() {
@@ -39,17 +39,16 @@ this.checkVersions = function() {
     //     "host":"kinto-ota.dev.mozaws.net",
     //     "last_modified":1450717104423,
     //     "bucket":"blocklists",
     //     "collection":"certificates"
     //    }]}
     // Right now, we only use the collection name and the last modified info
     let kintoBase = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
     let changesEndpoint = kintoBase + Services.prefs.getCharPref(PREF_BLOCKLIST_CHANGES_PATH);
-    let blocklistsBucket = Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET);
 
     // Use ETag to obtain a `304 Not modified` when no change occurred.
     const headers = {};
     if (Services.prefs.prefHasUserValue(PREF_BLOCKLIST_LAST_ETAG)) {
       const lastEtag = Services.prefs.getCharPref(PREF_BLOCKLIST_LAST_ETAG);
       if (lastEtag) {
         headers["If-None-Match"] = lastEtag;
       }
@@ -77,24 +76,21 @@ this.checkVersions = function() {
     // negative clockDifference means local time is behind server time
     // by the absolute of that value in seconds (positive means it's ahead)
     let clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
     Services.prefs.setIntPref(PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, clockDifference);
     Services.prefs.setIntPref(PREF_BLOCKLIST_LAST_UPDATE, serverTimeMillis / 1000);
 
     let firstError;
     for (let collectionInfo of versionInfo.data) {
-      // Skip changes that don't concern configured blocklist bucket.
-      if (collectionInfo.bucket != blocklistsBucket) {
-        continue;
-      }
-
       let collection = collectionInfo.collection;
       let client = gBlocklistClients[collection];
-      if (client && client.maybeSync) {
+      if (client &&
+          client.bucketName == collectionInfo.bucket &&
+          client.maybeSync) {
         let lastModified = 0;
         if (collectionInfo.last_modified) {
           lastModified = collectionInfo.last_modified;
         }
         try {
           yield client.maybeSync(lastModified, serverTimeMillis);
         } catch (e) {
           if (!firstError) {
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_pinning.js
@@ -0,0 +1,298 @@
+"use strict"
+
+const { Constructor: CC } = Components;
+
+Cu.import("resource://testing-common/httpd.js");
+
+const { Kinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+const { FirefoxAdapter } = Cu.import("resource://services-common/kinto-storage-adapter.js");
+
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+  "nsIBinaryInputStream", "setInputStream");
+
+const PREF_BLOCKLIST_PINNING_COLLECTION = "services.blocklist.pinning.collection";
+const COLLECTION_NAME = "pins";
+const KINTO_STORAGE_PATH    = "kinto.sqlite";
+
+// First, we need to setup appInfo or we can't do version checks
+var id = "xpcshell@tests.mozilla.org";
+var appName = "XPCShell";
+var version = "1";
+var platformVersion = "1.9.2";
+Cu.import("resource://testing-common/AppInfo.jsm", this);
+
+updateAppInfo({
+  name: appName,
+  ID: id,
+  version: version,
+  platformVersion: platformVersion ? platformVersion : "1.0",
+  crashReporter: true,
+});
+
+let server;
+
+
+function do_get_kinto_collection(connection, collectionName) {
+  let config = {
+    // Set the remote to be some server that will cause test failure when
+    // hit since we should never hit the server directly (any non-local
+    // request causes failure in tests), only via maybeSync()
+    remote: "https://firefox.settings.services.mozilla.com/v1/",
+    // Set up the adapter and bucket as normal
+    adapter: FirefoxAdapter,
+    adapterOptions: {sqliteHandle: connection},
+    bucket: "pinning"
+  };
+  let kintoClient = new Kinto(config);
+  return kintoClient.collection(collectionName);
+}
+
+// Some simple tests to demonstrate that the core preload sync operations work
+// correctly and that simple kinto operations are working as expected.
+add_task(function* test_something(){
+  // set the collection name explicitly - since there will be version
+  // specific collection names in prefs
+  Services.prefs.setCharPref(PREF_BLOCKLIST_PINNING_COLLECTION,
+                             COLLECTION_NAME);
+
+  const { PinningPreloadClient } = Cu.import("resource://services-common/blocklist-clients.js");
+
+  const configPath = "/v1/";
+  const recordsPath = "/v1/buckets/pinning/collections/pins/records";
+
+  Services.prefs.setCharPref("services.settings.server",
+                             `http://localhost:${server.identity.primaryPort}/v1`);
+
+  // register a handler
+  function handleResponse (request, response) {
+    try {
+      const sample = getSampleResponse(request, server.identity.primaryPort);
+      if (!sample) {
+        do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
+      }
+
+      response.setStatusLine(null, sample.status.status,
+                             sample.status.statusText);
+      // send the headers
+      for (let headerLine of sample.sampleHeaders) {
+        let headerElements = headerLine.split(':');
+        response.setHeader(headerElements[0], headerElements[1].trimLeft());
+      }
+      response.setHeader("Date", (new Date()).toUTCString());
+
+      response.write(sample.responseBody);
+    } catch (e) {
+      do_print(e);
+    }
+  }
+  server.registerPathHandler(configPath, handleResponse);
+  server.registerPathHandler(recordsPath, handleResponse);
+
+  let sss = Cc["@mozilla.org/ssservice;1"]
+              .getService(Ci.nsISiteSecurityService);
+
+  // ensure our pins are all missing before we start
+  ok(!sss.isSecureHost(sss.HEADER_HPKP, "one.example.com", 0));
+  ok(!sss.isSecureHost(sss.HEADER_HPKP, "two.example.com", 0));
+  ok(!sss.isSecureHost(sss.HEADER_HPKP, "three.example.com", 0));
+
+  // Test an empty db populates
+  let result = yield PinningPreloadClient.maybeSync(2000, Date.now());
+
+  let connection = yield FirefoxAdapter.openConnection({path: KINTO_STORAGE_PATH});
+
+  // Open the collection, verify it's been populated:
+  // Our test data has a single record; it should be in the local collection
+  let collection = do_get_kinto_collection(connection, COLLECTION_NAME);
+  let list = yield collection.list();
+  do_check_eq(list.data.length, 1);
+
+  // check that a pin exists for one.example.com
+  ok(sss.isSecureHost(sss.HEADER_HPKP, "one.example.com", 0));
+
+  // Test the db is updated when we call again with a later lastModified value
+  result = yield PinningPreloadClient.maybeSync(4000, Date.now());
+
+  // Open the collection, verify it's been updated:
+  // Our data now has three new records; all should be in the local collection
+  collection = do_get_kinto_collection(connection, COLLECTION_NAME);
+  list = yield collection.list();
+  do_check_eq(list.data.length, 4);
+  yield connection.close();
+
+  // check that a pin exists for two.example.com and three.example.com
+  ok(sss.isSecureHost(sss.HEADER_HPKP, "two.example.com", 0));
+  ok(sss.isSecureHost(sss.HEADER_HPKP, "three.example.com", 0));
+
+  // check that a pin does not exist for four.example.com - it's in the
+  // collection but the version should not match
+  ok(!sss.isSecureHost(sss.HEADER_HPKP, "four.example.com", 0));
+
+  // Try to maybeSync with the current lastModified value - no connection
+  // should be attempted.
+  // Clear the kinto base pref so any connections will cause a test failure
+  Services.prefs.clearUserPref("services.settings.server");
+  yield PinningPreloadClient.maybeSync(4000, Date.now());
+
+  // Try again with a lastModified value at some point in the past
+  yield PinningPreloadClient.maybeSync(3000, Date.now());
+
+  // Check the pinning check time pref is modified, even if the collection
+  // hasn't changed
+  Services.prefs.setIntPref("services.blocklist.onecrl.checked", 0);
+  yield PinningPreloadClient.maybeSync(3000, Date.now());
+  let newValue = Services.prefs.getIntPref("services.blocklist.pinning.checked");
+  do_check_neq(newValue, 0);
+
+  // Check that a sync completes even when there's bad data in the
+  // collection. This will throw on fail, so just calling maybeSync is an
+  // acceptible test (the data below with last_modified of 300 is nonsense).
+  Services.prefs.setCharPref("services.settings.server",
+                             `http://localhost:${server.identity.primaryPort}/v1`);
+  yield PinningPreloadClient.maybeSync(5000, Date.now());
+});
+
+function run_test() {
+  // Ensure that signature verification is disabled to prevent interference
+  // with basic certificate sync tests
+  Services.prefs.setBoolPref("services.blocklist.signing.enforced", false);
+
+  // Set up an HTTP Server
+  server = new HttpServer();
+  server.start(-1);
+
+  run_next_test();
+
+  do_register_cleanup(function() {
+    server.stop(() => { });
+  });
+}
+
+// get a response for a given request from sample data
+function getSampleResponse(req, port) {
+  const appInfo = Cc["@mozilla.org/xre/app-info;1"]
+                     .getService(Ci.nsIXULAppInfo);
+
+  const responses = {
+    "OPTIONS": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
+        "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
+        "Access-Control-Allow-Origin: *",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress"
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": "null"
+    },
+    "GET:/v1/?": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress"
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"settings":{"batch_max_requests":25}, "url":`http://localhost:${port}/v1/`, "documentation":"https://kinto.readthedocs.org/", "version":"1.5.1", "commit":"cbc6f58", "hello":"kinto"})
+    },
+    "GET:/v1/buckets/pinning/collections/pins/records?_sort=-last_modified": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"3000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "pinType": "KeyPin",
+        "hostName": "one.example.com",
+        "includeSubdomains": false,
+        "expires": new Date().getTime() + 1000000,
+        "pins" : ["cUPcTAZWKaASuYWhhneDttWpY3oBAkE3h2+soZS7sWs=",
+                  "M8HztCzM3elUxkcjR2S5P4hhyBNf6lHkmjAHKhpGPWE="],
+        "versions" : [appInfo.version],
+        "id":"78cf8900-fdea-4ce5-f8fb-b78710617718",
+        "last_modified":3000
+      }]})
+    },
+    "GET:/v1/buckets/pinning/collections/pins/records?_sort=-last_modified&_since=3000": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"4000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "pinType": "KeyPin",
+        "hostName": "two.example.com",
+        "includeSubdomains": false,
+        "expires": new Date().getTime() + 1000000,
+        "pins" : ["cUPcTAZWKaASuYWhhneDttWpY3oBAkE3h2+soZS7sWs=",
+                  "M8HztCzM3elUxkcjR2S5P4hhyBNf6lHkmjAHKhpGPWE="],
+        "versions" : [appInfo.version],
+        "id":"dabafde9-df4a-ddba-2548-748da04cc02c",
+        "last_modified":4000
+      },{
+        "pinType": "KeyPin",
+        "hostName": "three.example.com",
+        "includeSubdomains": false,
+        "expires": new Date().getTime() + 1000000,
+        "pins" : ["cUPcTAZWKaASuYWhhneDttWpY3oBAkE3h2+soZS7sWs=",
+                  "M8HztCzM3elUxkcjR2S5P4hhyBNf6lHkmjAHKhpGPWE="],
+        "versions" : [appInfo.version, "some other version that won't match"],
+        "id":"dabafde9-df4a-ddba-2548-748da04cc02d",
+        "last_modified":4000
+      },{
+        "pinType": "KeyPin",
+        "hostName": "four.example.com",
+        "includeSubdomains": false,
+        "expires": new Date().getTime() + 1000000,
+        "pins" : ["cUPcTAZWKaASuYWhhneDttWpY3oBAkE3h2+soZS7sWs=",
+                  "M8HztCzM3elUxkcjR2S5P4hhyBNf6lHkmjAHKhpGPWE="],
+        "versions" : ["some version that won't match"],
+        "id":"dabafde9-df4a-ddba-2548-748da04cc02e",
+        "last_modified":4000
+      }]})
+    },
+    "GET:/v1/buckets/pinning/collections/pins/records?_sort=-last_modified&_since=4000": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"5000\""
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({"data":[{
+        "irrelevant":"this entry looks nothing whatsoever like a pin preload",
+        "pinType": "KeyPin",
+        "id":"dabafde9-df4a-ddba-2548-748da04cc02f",
+        "last_modified":5000
+      },{
+        "irrelevant":"this entry has data of the wrong type",
+        "pinType": "KeyPin",
+        "hostName": 3,
+        "includeSubdomains": "nonsense",
+        "expires": "more nonsense",
+        "pins" : [1,2,3,4],
+        "id":"dabafde9-df4a-ddba-2548-748da04cc030",
+        "last_modified":5000
+      },{
+        "irrelevant":"this entry is missing the actual pins",
+        "pinType": "KeyPin",
+        "hostName": "missingpins.example.com",
+        "includeSubdomains": false,
+        "expires": new Date().getTime() + 1000000,
+        "versions" : [appInfo.version],
+        "id":"dabafde9-df4a-ddba-2548-748da04cc031",
+        "last_modified":5000
+      }]})
+    }
+  };
+  return responses[`${req.method}:${req.path}?${req.queryString}`] ||
+         responses[req.method];
+
+}
--- a/services/common/tests/unit/test_blocklist_updater.js
+++ b/services/common/tests/unit/test_blocklist_updater.js
@@ -52,16 +52,17 @@ add_task(function* test_check_maybeSync(
   let startTime = Date.now();
 
   let updater = Cu.import("resource://services-common/blocklist-updater.js");
 
   let syncPromise = new Promise(function(resolve, reject) {
     // add a test kinto client that will respond to lastModified information
     // for a collection called 'test-collection'
     updater.addTestBlocklistClient("test-collection", {
+      bucketName: "blocklists",
       maybeSync(lastModified, serverTime) {
         do_check_eq(lastModified, 1000);
         do_check_eq(serverTime, 2000);
         resolve();
       }
     });
     updater.checkVersions();
   });
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -7,16 +7,17 @@ support-files =
   test_blocklist_signatures/**
 
 # Test load modules first so syntax failures are caught early.
 [test_load_modules.js]
 
 [test_blocklist_certificates.js]
 [test_blocklist_clients.js]
 [test_blocklist_updater.js]
+[test_blocklist_pinning.js]
 
 [test_kinto.js]
 [test_blocklist_signatures.js]
 [test_storage_adapter.js]
 
 [test_utils_atob.js]
 [test_utils_convert_string.js]
 [test_utils_dateprefs.js]