diff --git a/extensions/chromium/pdfHandler.html b/extensions/chromium/pdfHandler.html index e412ca1ae..728a038c3 100644 --- a/extensions/chromium/pdfHandler.html +++ b/extensions/chromium/pdfHandler.html @@ -22,3 +22,4 @@ limitations under the License. + diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 921ef28df..c4ea94889 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -88,6 +88,12 @@ 4 ], "default": 0 + }, + "disableTelemetry": { + "title": "Disable telemetry", + "type": "boolean", + "description": "Whether to prevent the extension from reporting the extension and browser version to the extension developers.", + "default": false } } } diff --git a/extensions/chromium/telemetry.js b/extensions/chromium/telemetry.js new file mode 100644 index 000000000..04061681d --- /dev/null +++ b/extensions/chromium/telemetry.js @@ -0,0 +1,162 @@ +/* +Copyright 2016 Mozilla Foundation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* globals chrome, crypto, Headers, Request */ + +(function() { + 'use strict'; + // This module sends the browser and extension version to a server, to + // determine whether it is safe to drop support for old Chrome versions in + // future extension updates. + // + // The source code for the server is available at: + // https://github.com/Rob--W/pdfjs-telemetry + var LOG_URL = 'https://pdfjs.robwu.nl/logpdfjs'; + + // The minimum time to wait before sending a ping, so that we don't send too + // many requests even if the user restarts their browser very often. + // We want one ping a day, so a minimum delay of 12 hours should be OK. + var MINIMUM_TIME_BETWEEN_PING = 12 * 36E5; + + if (chrome.extension.inIncognitoContext) { + // The extension uses incognito split mode, so there are two background + // pages. Only send telemetry when not in incognito mode. + return; + } + + if (chrome.runtime.id !== 'oemmndcbldboiebfnladdacbdfmadadm') { + // Only send telemetry for the official PDF.js extension. + console.warn('Disabled telemetry because this is not an official build.'); + return; + } + + maybeSendPing(); + setInterval(maybeSendPing, 36E5); + + function maybeSendPing() { + getLoggingPref(function(didOptOut) { + if (didOptOut) { + // Respect the user's decision to not send statistics. + return; + } + if (!navigator.onLine) { + // No network available; Wait until the next scheduled ping opportunity. + // Even if onLine is true, the server may still be unreachable. But + // because it is impossible to tell whether a request failed due to the + // inability to connect, or a deliberate connection termination by the + // server, we don't validate the response and assume that the request + // succeeded. This ensures that the server cannot ask the client to + // send more pings. + return; + } + var lastTime = parseInt(localStorage.telemetryLastTime) || 0; + var wasUpdated = didUpdateSinceLastCheck(); + if (!wasUpdated && Date.now() - lastTime < MINIMUM_TIME_BETWEEN_PING) { + return; + } + localStorage.telemetryLastTime = Date.now(); + + var deduplication_id = getDeduplicationId(wasUpdated); + var extension_version = chrome.runtime.getManifest().version; + if (window.Request && 'mode' in Request.prototype) { + // fetch is supported in extensions since Chrome 42 (though the above + // feature-detection method detects selects Chrome 43+). + // Unlike XMLHttpRequest, fetch omits credentials such as cookies in the + // requests, which guarantees that the server cannot track the client + // via HTTP cookies. + fetch(LOG_URL, { + method: 'POST', + headers: new Headers({ + 'Deduplication-Id': deduplication_id, + 'Extension-Version': extension_version, + }), + // Set mode=cors so that the above custom headers are included in the + // request. + mode: 'cors', + }); + return; + } + var x = new XMLHttpRequest(); + x.open('POST', LOG_URL); + x.setRequestHeader('Deduplication-Id', deduplication_id); + x.setRequestHeader('Extension-Version', extension_version); + x.send(); + }); + } + + /** + * Generate a 40-bit hexadecimal string (=10 letters, 1.1E12 possibilities). + * This is used by the server to discard duplicate entries of the same browser + * version when the log data is aggregated. + */ + function getDeduplicationId(wasUpdated) { + var id = localStorage.telemetryDeduplicationId; + // The ID is only used to deduplicate reports for the same browser version, + // so it is OK to change the ID if the browser is updated. By changing the + // ID, the server cannot track users for a long period even if it wants to. + if (!id || !/^[0-9a-f]{10}$/.test(id) || wasUpdated) { + id = ''; + var buf = new Uint8Array(5); + crypto.getRandomValues(buf); + for (var i = 0; i < buf.length; ++i) { + var c = buf[i]; + id += (c >>> 4).toString(16) + (c & 0xF).toString(16); + } + localStorage.telemetryDeduplicationId = id; + } + return id; + } + + /** + * Returns whether the browser has received a major update since the last call + * to this function. + */ + function didUpdateSinceLastCheck() { + var chromeVersion = /Chrome\/(\d+)\./.exec(navigator.userAgent); + chromeVersion = chromeVersion && chromeVersion[1]; + if (!chromeVersion || localStorage.telemetryLastVersion === chromeVersion) { + return false; + } + localStorage.telemetryLastVersion = chromeVersion; + return true; + } + + /** + * Get the value of the telemetry preference. The callback is invoked with a + * boolean if a preference is found, and with the undefined value otherwise. + */ + function getLoggingPref(callback) { + // Try to look up the preference in the storage, in the following order: + var areas = ['sync', 'local', 'managed']; + + next(); + function next(result) { + var storageAreaName = areas.shift(); + if (typeof result === 'boolean' || !storageAreaName) { + callback(result); + return; + } + + if (!chrome.storage[storageAreaName]) { + next(); + return; + } + + chrome.storage[storageAreaName].get('disableTelemetry', function(items) { + next(items && items.disableTelemetry); + }); + } + } +})(); diff --git a/gulpfile.js b/gulpfile.js index 0ac9dc1dc..b13865343 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -79,6 +79,13 @@ function checkChromePreferencesFile(chromePrefsPath, webPrefsPath) { var webPrefs = JSON.parse(fs.readFileSync(webPrefsPath).toString()); var webPrefsKeys = Object.keys(webPrefs); webPrefsKeys.sort(); + var telemetryIndex = chromePrefsKeys.indexOf('disableTelemetry'); + if (telemetryIndex >= 0) { + chromePrefsKeys.splice(telemetryIndex, 1); + } else { + console.log('Warning: disableTelemetry key not found in chrome prefs!'); + return false; + } if (webPrefsKeys.length !== chromePrefsKeys.length) { return false; } diff --git a/test/chromium/test-telemetry.js b/test/chromium/test-telemetry.js new file mode 100755 index 000000000..a290c0866 --- /dev/null +++ b/test/chromium/test-telemetry.js @@ -0,0 +1,411 @@ +#!/usr/bin/env node +/* Copyright 2016 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var fs = require('fs'); +var vm = require('vm'); + +var SRC_DIR = __dirname + '/../../'; +var telemetryJsPath = 'extensions/chromium/telemetry.js'; +var telemetryJsSource = fs.readFileSync(SRC_DIR + telemetryJsPath); +var telemetryScript = new vm.Script(telemetryJsSource, { + filename: telemetryJsPath, + displayErrors: true, +}); +var LOG_URL = /LOG_URL = '([^']+)'/.exec(telemetryJsSource)[1]; + +// Create a minimal extension global that mocks the extension environment that +// is used by telemetry.js +function createExtensionGlobal() { + var window = {}; + + // Whenever a "request" was made, the extra headers are appended to this list. + var test_requests = window.test_requests = []; + + // Extension API mocks. + window.window = window; + window.chrome = {}; + window.chrome.extension = {}; + window.chrome.extension.inIncognitoContext = false; + window.chrome.runtime = {}; + window.chrome.runtime.id = 'oemmndcbldboiebfnladdacbdfmadadm'; + window.chrome.runtime.getManifest = function() { + return {version: '1.0.0'}; + }; + + function createStorageAPI() { + var storageArea = {}; + storageArea.get = function(key, callback) { + assert.equal(key, 'disableTelemetry'); + // chrome.storage.*. is async, but we make it synchronous to ease testing. + callback(storageArea.mock_data); + }; + return storageArea; + } + window.chrome.storage = {}; + window.chrome.storage.managed = createStorageAPI(); + window.chrome.storage.local = createStorageAPI(); + window.chrome.storage.sync = createStorageAPI(); + + // Other DOM. + window.navigator = {}; + window.navigator.onLine = true; + window.navigator.userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/50.0.2661.94 Safari/537.36'; + window.localStorage = {}; + + var getRandomValues_state = 0; + window.crypto = {}; + window.crypto.getRandomValues = function(buf) { + var state = getRandomValues_state++; + for (var i = 0; i < buf.length; ++i) { + // Totally random byte ;) + buf[i] = 0x42 + state; + } + return buf; + }; + + // Network-related mocks. + window.Request = {}; + window.Request.prototype = { + get mode() { throw new TypeError('Illegal invocation'); }, + }; + window.fetch = function(url, options) { + assert.equal(url, LOG_URL); + assert.equal(options.method, 'POST'); + assert.equal(options.mode, 'cors'); + assert.ok(!options.body); + test_requests.push(options.headers); + }; + window.Headers = function(headers) { + headers = JSON.parse(JSON.stringify(headers)); // Clone. + Object.keys(headers).forEach(function(k) { + headers[k] = String(headers[k]); + }); + return headers; + }; + window.XMLHttpRequest = function() { + var invoked = { + open: false, + send: false, + }; + var headers = {}; + return { + open: function(method, url) { + assert.equal(invoked.open, false); + invoked.open = true; + assert.equal(method, 'POST'); + assert.equal(url, LOG_URL); + }, + setRequestHeader: function(k, v) { + assert.equal(invoked.open, true); + headers[k] = String(v); + }, + send: function(body) { + assert.equal(invoked.open, true); + assert.equal(invoked.send, false); + invoked.send = true; + assert.ok(!body); + test_requests.push(headers); + }, + }; + }; + + // Time-related logic. + var timers = []; + window.setInterval = function(callback, ms) { + assert.equal(typeof callback, 'function'); + timers.push(callback); + }; + window.Date = { + test_now_value: Date.now(), + now: function() { + return window.Date.test_now_value; + }, + }; + window.test_fireTimers = function() { + assert.ok(timers.length); + timers.forEach(function(timer) { + timer(); + }); + }; + + return window; +} + +// Simulate an update of the browser. +function updateBrowser(window) { + window.navigator.userAgent = + window.navigator.userAgent.replace(/Chrome\/(\d+)/, function(_, v) { + return 'Chrome/' + (parseInt(v) + 1); + }); +} + +var tests = [ + function test_first_run() { + // Default settings, run extension for the first time. + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_first_run_incognito() { + // The extension should not send any requests when in incognito mode. + var window = createExtensionGlobal(); + window.chrome.extension.inIncognitoContext = true; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, []); + }, + + function test_storage_managed_unavailable() { + var window = createExtensionGlobal(); + delete window.chrome.storage.managed; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_managed_pref() { + var window = createExtensionGlobal(); + window.chrome.storage.managed.mock_data = { + disableTelemetry: true, + }; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, []); + }, + + function test_local_pref() { + var window = createExtensionGlobal(); + window.chrome.storage.local.mock_data = { + disableTelemetry: true, + }; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, []); + }, + + function test_managed_pref_is_overridden() { + var window = createExtensionGlobal(); + window.chrome.storage.managed.mock_data = { + disableTelemetry: true, + }; + window.chrome.storage.sync.mock_data = { + disableTelemetry: false, + }; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_run_extension_again() { + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + telemetryScript.runInNewContext(window); + // Only one request should be sent because of rate-limiting. + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + + // Simulate that quite some hours passed, but it's still rate-limited. + window.Date.test_now_value += 11 * 36E5; + telemetryScript.runInNewContext(window); + // Only one request should be sent because of rate-limiting. + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + + // Another hour passes and the request should not be rate-limited any more. + window.Date.test_now_value += 1 * 36E5; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }, { + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_running_for_a_while() { + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + + // Simulate that the timer fired 11 hours since the last ping. The request + // should still be rate-limited. + window.Date.test_now_value += 11 * 36E5; + window.test_fireTimers(); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + + // Another hour passes and the request should not be rate-limited any more. + window.Date.test_now_value += 1 * 36E5; + window.test_fireTimers(); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }, { + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_browser_update() { + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + updateBrowser(window); + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }, { + // Generate a new ID for better privacy. + 'Deduplication-Id': '4343434343', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_browser_update_between_pref_toggle() { + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + window.chrome.storage.local.mock_data = { + disableTelemetry: true, + }; + updateBrowser(window); + telemetryScript.runInNewContext(window); + window.chrome.storage.local.mock_data = { + disableTelemetry: false, + }; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }, { + // Generate a new ID for better privacy, even if the update happened + // while telemetry was disabled. + 'Deduplication-Id': '4343434343', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_extension_update() { + var window = createExtensionGlobal(); + telemetryScript.runInNewContext(window); + window.chrome.runtime.getManifest = function() { + return {version: '1.0.1'}; + }; + window.Date.test_now_value += 12 * 36E5; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }, { + // The ID did not change because the browser version did not change. + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.1', + }]); + }, + + function test_unofficial_build() { + var window = createExtensionGlobal(); + var didWarn = false; + window.console = {}; + window.console.warn = function() { didWarn = true; }; + window.chrome.runtime.id = 'abcdefghijklmnopabcdefghijklmnop'; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, []); + assert.ok(didWarn); + }, + + function test_fetch_is_supported() { + var window = createExtensionGlobal(); + // XMLHttpRequest should not be called when fetch is available. So removing + // the XMLHttpRequest API should not change behavior. + delete window.XMLHttpRequest; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_fetch_not_supported() { + var window = createExtensionGlobal(); + delete window.fetch; + delete window.Request; + delete window.Headers; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_fetch_mode_not_supported() { + var window = createExtensionGlobal(); + delete window.Request.prototype.mode; + window.fetch = function() { throw new Error('Unexpected call to fetch!'); }; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, + + function test_network_offline() { + var window = createExtensionGlobal(); + // Simulate that the network is down for sure. + window.navigator.onLine = false; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, []); + + // Simulate that the network might be up. + window.navigator.onLine = true; + telemetryScript.runInNewContext(window); + assert.deepEqual(window.test_requests, [{ + 'Deduplication-Id': '4242424242', + 'Extension-Version': '1.0.0', + }]); + }, +]; +var test_i = 0; + +(function next() { + var test = tests[test_i++]; + if (!test) { + console.log('All tests completed.'); + return; + } + console.log('Running test ' + test_i + '/' + tests.length + ': ' + test.name); + test(); + process.nextTick(next); +})();