summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm
blob: c6324da0a79fd367069fa46b61de14746db03983 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
/* 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";

const { classes: Cc, interfaces: Ci, utils: Cu } = Components;

const LOCAL_EME_SOURCES = [{
  "id": "gmp-gmpopenh264",
  "src": "chrome://global/content/gmp-sources/openh264.json"
}, {
  "id": "gmp-widevinecdm",
  "src": "chrome://global/content/gmp-sources/widevinecdm.json"
}];

this.EXPORTED_SYMBOLS = [ "ProductAddonChecker" ];

Cu.importGlobalProperties(["XMLHttpRequest"]);

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/CertUtils.jsm");
/* globals checkCert, BadCertHandler*/
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/osfile.jsm");

/* globals GMPPrefs */
XPCOMUtils.defineLazyModuleGetter(this, "GMPPrefs",
                                  "resource://gre/modules/GMPUtils.jsm");

/* globals OS */

XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
                                  "resource://gre/modules/UpdateUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "ServiceRequest",
                                  "resource://gre/modules/ServiceRequest.jsm");

// This exists so that tests can override the XHR behaviour for downloading
// the addon update XML file.
var CreateXHR = function() {
  return Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
  createInstance(Ci.nsISupports);
}

var logger = Log.repository.getLogger("addons.productaddons");

/**
 * Number of milliseconds after which we need to cancel `downloadXML`.
 *
 * Bug 1087674 suggests that the XHR we use in `downloadXML` may
 * never terminate in presence of network nuisances (e.g. strange
 * antivirus behavior). This timeout is a defensive measure to ensure
 * that we fail cleanly in such case.
 */
const TIMEOUT_DELAY_MS = 20000;
// Chunk size for the incremental downloader
const DOWNLOAD_CHUNK_BYTES_SIZE = 300000;
// Incremental downloader interval
const DOWNLOAD_INTERVAL  = 0;
// How much of a file to read into memory at a time for hashing
const HASH_CHUNK_SIZE = 8192;

/**
 * Gets the status of an XMLHttpRequest either directly or from its underlying
 * channel.
 *
 * @param  request
 *         The XMLHttpRequest.
 * @return an integer status value.
 */
function getRequestStatus(request) {
  let status = null;
  try {
    status = request.status;
  }
  catch (e) {
  }

  if (status != null) {
    return status;
  }

  return request.channel.QueryInterface(Ci.nsIRequest).status;
}

/**
 * Downloads an XML document from a URL optionally testing the SSL certificate
 * for certain attributes.
 *
 * @param  url
 *         The url to download from.
 * @param  allowNonBuiltIn
 *         Whether to trust SSL certificates without a built-in CA issuer.
 * @param  allowedCerts
 *         The list of certificate attributes to match the SSL certificate
 *         against or null to skip checks.
 * @return a promise that resolves to the DOM document downloaded or rejects
 *         with a JS exception in case of error.
 */
function downloadXML(url, allowNonBuiltIn = false, allowedCerts = null) {
  return new Promise((resolve, reject) => {
    let request = CreateXHR();
    // This is here to let unit test code override XHR
    if (request.wrappedJSObject) {
      request = request.wrappedJSObject;
    }
    request.open("GET", url, true);
    request.channel.notificationCallbacks = new BadCertHandler(allowNonBuiltIn);
    // Prevent the request from reading from the cache.
    request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
    // Prevent the request from writing to the cache.
    request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
    // Use conservative TLS settings. See bug 1325501.
    // TODO move to ServiceRequest.
    if (request.channel instanceof Ci.nsIHttpChannelInternal) {
      request.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = true;
    }
    request.timeout = TIMEOUT_DELAY_MS;

    request.overrideMimeType("text/xml");
    // The Cache-Control header is only interpreted by proxies and the
    // final destination. It does not help if a resource is already
    // cached locally.
    request.setRequestHeader("Cache-Control", "no-cache");
    // HTTP/1.0 servers might not implement Cache-Control and
    // might only implement Pragma: no-cache
    request.setRequestHeader("Pragma", "no-cache");

    let fail = (event) => {
      let request = event.target;
      let status = getRequestStatus(request);
      let message = "Failed downloading XML, status: " + status +  ", reason: " + event.type;
      logger.warn(message);
      let ex = new Error(message);
      ex.status = status;
      reject(ex);
    };

    let success = (event) => {
      logger.info("Completed downloading document");
      let request = event.target;

      try {
        checkCert(request.channel, allowNonBuiltIn, allowedCerts);
      } catch (ex) {
        logger.error("Request failed certificate checks: " + ex);
        ex.status = getRequestStatus(request);
        reject(ex);
        return;
      }

      resolve(request.responseXML);
    };

    request.addEventListener("error", fail, false);
    request.addEventListener("abort", fail, false);
    request.addEventListener("timeout", fail, false);
    request.addEventListener("load", success, false);

    logger.info("sending request to: " + url);
    request.send(null);
  });
}

function downloadJSON(uri) {
  logger.info("fetching config from: " + uri);
  return new Promise((resolve, reject) => {
    let xmlHttp = new ServiceRequest({mozAnon: true});

    xmlHttp.onload = function(aResponse) {
      resolve(JSON.parse(this.responseText));
    };

    xmlHttp.onerror = function(e) {
      reject("Fetching " + uri + " results in error code: " + e.target.status);
    };

    xmlHttp.open("GET", uri);
    xmlHttp.overrideMimeType("application/json");
    xmlHttp.send();
  });
}


/**
 * Parses a list of add-ons from a DOM document.
 *
 * @param  document
 *         The DOM document to parse.
 * @return null if there is no <addons> element otherwise an object containing
 *         an array of the addons listed and a field notifying whether the
 *         fallback was used.
 */
function parseXML(document) {
  // Check that the root element is correct
  if (document.documentElement.localName != "updates") {
    throw new Error("got node name: " + document.documentElement.localName +
                    ", expected: updates");
  }

  // Check if there are any addons elements in the updates element
  let addons = document.querySelector("updates:root > addons");
  if (!addons) {
    return null;
  }

  let results = [];
  let addonList = document.querySelectorAll("updates:root > addons > addon");
  for (let addonElement of addonList) {
    let addon = {};

    for (let name of ["id", "URL", "hashFunction", "hashValue", "version", "size"]) {
      if (addonElement.hasAttribute(name)) {
        addon[name] = addonElement.getAttribute(name);
      }
    }
    addon.size = Number(addon.size) || undefined;

    results.push(addon);
  }

  return {
    usedFallback: false,
    gmpAddons: results
  };
}

/**
 * If downloading from the network fails (AUS server is down),
 * load the sources from local build configuration.
 */
function downloadLocalConfig() {

  if (!GMPPrefs.get(GMPPrefs.KEY_UPDATE_ENABLED, true)) {
    logger.info("Updates are disabled via media.gmp-manager.updateEnabled");
    return Promise.resolve({usedFallback: true, gmpAddons: []});
  }

  return Promise.all(LOCAL_EME_SOURCES.map(conf => {
    return downloadJSON(conf.src).then(addons => {

      let platforms = addons.vendors[conf.id].platforms;
      let target = Services.appinfo.OS + "_" + UpdateUtils.ABI;
      let details = null;

      while (!details) {
        if (!(target in platforms)) {
          // There was no matching platform so return false, this addon
          // will be filtered from the results below
          logger.info("no details found for: " + target);
          return false;
        }
        // Field either has the details of the binary or is an alias
        // to another build target key that does
        if (platforms[target].alias) {
          target = platforms[target].alias;
        } else {
          details = platforms[target];
        }
      }

      logger.info("found plugin: " + conf.id);
      return {
        "id": conf.id,
        "URL": details.fileUrl,
        "hashFunction": addons.hashFunction,
        "hashValue": details.hashValue,
        "version": addons.vendors[conf.id].version,
        "size": details.filesize
      };
    });
  })).then(addons => {

    // Some filters may not match this platform so
    // filter those out
    addons = addons.filter(x => x !== false);

    return {
      usedFallback: true,
      gmpAddons: addons
    };
  });
}

/**
 * Downloads file from a URL using XHR.
 *
 * @param  url
 *         The url to download from.
 * @return a promise that resolves to the path of a temporary file or rejects
 *         with a JS exception in case of error.
 */
function downloadFile(url) {
  return new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.onload = function(response) {
      logger.info("downloadXHR File download. status=" + xhr.status);
      if (xhr.status != 200 && xhr.status != 206) {
        reject(Components.Exception("File download failed", xhr.status));
        return;
      }
      Task.spawn(function* () {
        let f = yield OS.File.openUnique(OS.Path.join(OS.Constants.Path.tmpDir, "tmpaddon"));
        let path = f.path;
        logger.info(`Downloaded file will be saved to ${path}`);
        yield f.file.close();
        yield OS.File.writeAtomic(path, new Uint8Array(xhr.response));
        return path;
      }).then(resolve, reject);
    };

    let fail = (event) => {
      let request = event.target;
      let status = getRequestStatus(request);
      let message = "Failed downloading via XHR, status: " + status +  ", reason: " + event.type;
      logger.warn(message);
      let ex = new Error(message);
      ex.status = status;
      reject(ex);
    };
    xhr.addEventListener("error", fail);
    xhr.addEventListener("abort", fail);

    xhr.responseType = "arraybuffer";
    try {
      xhr.open("GET", url);
      // Use conservative TLS settings. See bug 1325501.
      // TODO move to ServiceRequest.
      if (xhr.channel instanceof Ci.nsIHttpChannelInternal) {
        xhr.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = true;
      }
      xhr.send(null);
    } catch (ex) {
      reject(ex);
    }
  });
}

/**
 * Convert a string containing binary values to hex.
 */
function binaryToHex(input) {
  let result = "";
  for (let i = 0; i < input.length; ++i) {
    let hex = input.charCodeAt(i).toString(16);
    if (hex.length == 1) {
      hex = "0" + hex;
    }
    result += hex;
  }
  return result;
}

/**
 * Calculates the hash of a file.
 *
 * @param  hashFunction
 *         The type of hash function to use, must be supported by nsICryptoHash.
 * @param  path
 *         The path of the file to hash.
 * @return a promise that resolves to hash of the file or rejects with a JS
 *         exception in case of error.
 */
var computeHash = Task.async(function*(hashFunction, path) {
  let file = yield OS.File.open(path, { existing: true, read: true });
  try {
    let hasher = Cc["@mozilla.org/security/hash;1"].
                 createInstance(Ci.nsICryptoHash);
    hasher.initWithString(hashFunction);

    let bytes;
    do {
      bytes = yield file.read(HASH_CHUNK_SIZE);
      hasher.update(bytes, bytes.length);
    } while (bytes.length == HASH_CHUNK_SIZE);

    return binaryToHex(hasher.finish(false));
  }
  finally {
    yield file.close();
  }
});

/**
 * Verifies that a downloaded file matches what was expected.
 *
 * @param  properties
 *         The properties to check, `size` and `hashFunction` with `hashValue`
 *         are supported. Any properties missing won't be checked.
 * @param  path
 *         The path of the file to check.
 * @return a promise that resolves if the file matched or rejects with a JS
 *         exception in case of error.
 */
var verifyFile = Task.async(function*(properties, path) {
  if (properties.size !== undefined) {
    let stat = yield OS.File.stat(path);
    if (stat.size != properties.size) {
      throw new Error("Downloaded file was " + stat.size + " bytes but expected " + properties.size + " bytes.");
    }
  }

  if (properties.hashFunction !== undefined) {
    let expectedDigest = properties.hashValue.toLowerCase();
    let digest = yield computeHash(properties.hashFunction, path);
    if (digest != expectedDigest) {
      throw new Error("Hash was `" + digest + "` but expected `" + expectedDigest +  "`.");
    }
  }
});

const ProductAddonChecker = {
  /**
   * Downloads a list of add-ons from a URL optionally testing the SSL
   * certificate for certain attributes.
   *
   * @param  url
   *         The url to download from.
   * @param  allowNonBuiltIn
   *         Whether to trust SSL certificates without a built-in CA issuer.
   * @param  allowedCerts
   *         The list of certificate attributes to match the SSL certificate
   *         against or null to skip checks.
   * @return a promise that resolves to an object containing the list of add-ons
   *         and whether the local fallback was used, or rejects with a JS
   *         exception in case of error.
   */
  getProductAddonList: function(url, allowNonBuiltIn = false, allowedCerts = null) {
    if (!GMPPrefs.get(GMPPrefs.KEY_UPDATE_ENABLED, true)) {
      logger.info("Updates are disabled via media.gmp-manager.updateEnabled");
      return Promise.resolve({usedFallback: true, gmpAddons: []});
    }

    return downloadXML(url, allowNonBuiltIn, allowedCerts)
      .then(parseXML)
      .catch(downloadLocalConfig);
  },

  /**
   * Downloads an add-on to a local file and checks that it matches the expected
   * file. The caller is responsible for deleting the temporary file returned.
   *
   * @param  addon
   *         The addon to download.
   * @return a promise that resolves to the temporary file downloaded or rejects
   *         with a JS exception in case of error.
   */
  downloadAddon: Task.async(function*(addon) {
    let path = yield downloadFile(addon.URL);
    try {
      yield verifyFile(addon, path);
      return path;
    }
    catch (e) {
      yield OS.File.remove(path);
      throw e;
    }
  })
}