diff --git a/lib/wsapi/interaction_data.js b/lib/wsapi/interaction_data.js index 1b24bcb63e62673d34397b802c5718ec064c8660..d3f7573fd5f63a136f7d8a203d82c9e78b296f52 100644 --- a/lib/wsapi/interaction_data.js +++ b/lib/wsapi/interaction_data.js @@ -30,22 +30,15 @@ var store = function (kpi_json, cb) { logger.debug('KPI Backend responded ' + res.statusCode); }; - // Out of concern for the user's privacy, scrub the data of identifying - // details before storing it. - // - // - Remove the local_timestamp: This was necessary for the client to keep - // track of the start time for the event stream, but it should not be part - // of the final record. - // - // - Round the server timestamp off to the nearest 10-minute mark. + // Out of concern for the user's privacy, round the server timestamp + // off to the nearest 10-minute mark. und.each(kpi_json, function (kpi) { delete kpi.local_timestamp; - delete kpi.local_timestamp; - if (! kpi.timestamp) { kpi.timestamp = new Date().getTime(); } kpi.timestamp = kpi.timestamp - (kpi.timestamp % TEN_MIN_IN_MS); }); + if (!! config.get('kpi_backend_db_url')) { var post_data = querystring.stringify({ diff --git a/resources/static/shared/helpers.js b/resources/static/shared/helpers.js index 7f536f6b61a5ad65e156c14c86f14b239a060648..4c9f470421d1edc8a0b9d5a32b05be939e79d4fe 100644 --- a/resources/static/shared/helpers.js +++ b/resources/static/shared/helpers.js @@ -53,6 +53,16 @@ return url; } + function whitelistFilter(obj, validKeys) { + var filtered = {}; + for (var key in obj) { + if (validKeys.indexOf(key) !== -1) { + filtered[key] = obj[key]; + } + } + return filtered; + } + function cancelEvent(callback) { return function(event) { event && event.preventDefault(); @@ -112,6 +122,13 @@ */ toURL: toURL, + /** + * Filter an object by a whitelist of keys, returning a new object. + * @param {object} obj - the object to filter + * @param {object} [validKeys] - whitelisted keys + */ + whitelistFilter: whitelistFilter, + /** * Return a function that calls preventDefault on the event and then calls * the callback with the arguments. diff --git a/resources/static/shared/models/interaction_data.js b/resources/static/shared/models/interaction_data.js index d5ad94544acbc97e3712c26c0025caa6eed3cd3f..752825f13d6fc9a3c625684530247c40538e8dcc 100644 --- a/resources/static/shared/models/interaction_data.js +++ b/resources/static/shared/models/interaction_data.js @@ -9,7 +9,8 @@ BrowserID.Models.InteractionData = (function() { var bid = BrowserID, storage = bid.getStorage(), network = bid.Network, - complete = bid.Helpers.complete; + complete = bid.Helpers.complete, + whitelistFilter = bid.Helpers.whitelistFilter; function getInteractionData() { var interactionData; @@ -89,7 +90,22 @@ BrowserID.Models.InteractionData = (function() { // XXX: should we even try to post data if it's larger than some reasonable // threshold? if (data && data.length !== 0) { - network.sendInteractionData(data, function() { + + // Scrub the data we are going to send and let only a set of whitelisted + // keys through. This will remove such values as local_timestamp, which + // we needed to calculate time offsets in our event stream, but which + // could be used to fingerprint users. + var filtered = []; + data.forEach(function(obj) { + filtered.push(whitelistFilter(obj, [ + 'event_stream', + 'lang', + 'screen_size', + 'sample_rate'] + )); + }); + + network.sendInteractionData(filtered, function() { clearStaged(); complete(oncomplete, true); }, function(status) { diff --git a/resources/static/test/cases/shared/helpers.js b/resources/static/test/cases/shared/helpers.js index c29b406421024f1d23dfbc8ba8b0ab994b4c9a37..04a80eb168d7e5426715a039b307255cb76622d8 100644 --- a/resources/static/test/cases/shared/helpers.js +++ b/resources/static/test/cases/shared/helpers.js @@ -104,6 +104,21 @@ equal(url, "https://browserid.org?email=testuser%40testuser.com&status=complete", "correct URL with GET parameters"); }); + test("whitelistFilter an object", function() { + var unfiltered = { + 'event_stream': [ ['pie', 6], ['coffee', 19], ['flan', 42] ], + 'secret': "ATTACK AT DAWN!", + 'location': "Zeta Minor", + 'lang': 'auld' }; + + var filtered = helpers.whitelistFilter(unfiltered, ['event_stream', 'lang']); + equal(typeof filtered.secret, 'undefined', 'non-whitelisted key removed'); + equal(typeof filtered.location, 'undefined', 'non-whitelisted key removed'); + equal(filtered.lang, 'auld', 'whitelisted string passed'); + equal(filtered.event_stream.length, 3, 'whitelisted list passed'); + equal(filtered.event_stream[2][1], 42, 'whitelisted list elements preserved'); + }); + test("simulate log on browser without console - no exception thrown", function() { var err, nativeConsole = window.console; diff --git a/resources/static/test/cases/shared/models/interaction_data.js b/resources/static/test/cases/shared/models/interaction_data.js index 5454ccfc6e88f1e0a8cd1e250e132521d50192ff..b66ce318adfac786cd8ac04aece3d44d1f69e995 100644 --- a/resources/static/test/cases/shared/models/interaction_data.js +++ b/resources/static/test/cases/shared/models/interaction_data.js @@ -23,36 +23,36 @@ }); test("after push, most recently pushed data available through getCurrent, getStaged gets previous data sets", function() { - model.push({ foo: "bar" }); - equal(model.getCurrent().foo, "bar", + model.push({ lang: "foo" }); + equal(model.getCurrent().lang, "foo", "after pushing new interaction data, it's returned from .getCurrent()"); equal(model.getStaged().length, 0, "no data is yet staged"); - model.push({ foo: "baz" }); + model.push({ lang: "bar" }); - equal(model.getCurrent().foo, "baz", "current points to new data set") + equal(model.getCurrent().lang, "bar", "current points to new data set") var staged = model.getStaged(); equal(staged.length, 1, "only one staged item"); - testObjectValuesEqual(staged[0], { foo: "bar" }); + testObjectValuesEqual(staged[0], { lang: "foo" }); }); test("setCurrent data overwrites current", function() { model.clearStaged(); - model.push({ foo: "bar" }); - model.setCurrent({ foo: "baz" }); - equal(model.getCurrent().foo, "baz", + model.push({ lang: "foo" }); + model.setCurrent({ lang: "bar" }); + equal(model.getCurrent().lang, "bar", "overwriting current interaction data works"); }); test("clearStaged clears staged interaction data but leaves current data unaffected", function() { - model.push({ foo: "bar" }); - model.push({ foo: "baz" }); + model.push({ lang: "foo" }); + model.push({ lang: "bar" }); model.clearStaged(); equal(model.getStaged().length, 0, "after clearStageding, interaction data is zero length"); - equal(model.getCurrent().foo, "baz", + equal(model.getCurrent().lang, "bar", "after clearStageding, current data is unaffected"); }); @@ -61,7 +61,7 @@ model.stageCurrent(); equal(model.getStaged().length, 0, "no data to staged"); - model.push({ foo: "bar" }); + model.push({ lang: "foo" }); model.stageCurrent(); equal(model.getStaged().length, 1, "current data staged"); @@ -77,7 +77,7 @@ // desired result - data is purged from staging table // The first pushed data will become staged. - model.push({ foo: "bar" }); + model.push({ lang: "foo" }); model.stageCurrent(); xhr.useResult("throttle"); @@ -87,7 +87,7 @@ // When the interaction_data next completes, this will be the only data // that is pushed. - model.push({ foo: "baz" }); + model.push({ lang: "bar", secret: "Attack at dawn!!!" }); model.stageCurrent(); xhr.useResult("valid"); @@ -97,7 +97,8 @@ previousSessionsData = JSON.parse(request.data).data; equal(previousSessionsData.length, 1, "sending correct result sets"); - equal(previousSessionsData[0].foo, "baz", "correct data sent"); + equal(previousSessionsData[0].lang, "bar", "correct data sent"); + equal(typeof previousSessionsData[0].secret, "undefined", "non-whitelisted valued stripped"); start(); }); });